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,108 @@
|
|
|
1
|
+
import { readdirSync, statSync, unlinkSync, rmdirSync } from "node:fs";
|
|
2
|
+
import { join, relative, dirname } from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { readConfig } from "../config.js";
|
|
5
|
+
import { loadIndex } from "../index-store.js";
|
|
6
|
+
/**
|
|
7
|
+
* `memarium prune` — find raw_sessions/*.md files on disk that are NOT
|
|
8
|
+
* referenced by `.memarium/index.json` and (optionally) delete them.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: pre-0.7.1 the Copilot adapter could write the same
|
|
11
|
+
* sessionId to two different .md paths (chatSessions/ + transcripts/ both
|
|
12
|
+
* extracted as separate sources, different nameSlug/date, only one wins
|
|
13
|
+
* in the index). The losing write left an orphan .md on disk. 0.7.1 dedupes
|
|
14
|
+
* at discover time so new orphans stop accruing — `prune` cleans up the
|
|
15
|
+
* pre-existing ones.
|
|
16
|
+
*
|
|
17
|
+
* Dry-run by default. Pass `--apply` to actually delete. Empty parent
|
|
18
|
+
* directories are removed after their last file goes away.
|
|
19
|
+
*/
|
|
20
|
+
export async function pruneCmd(opts = {}) {
|
|
21
|
+
const cfg = readConfig();
|
|
22
|
+
const repoPath = opts.repoPath ?? cfg.repoPath;
|
|
23
|
+
const apply = opts.apply ?? false;
|
|
24
|
+
const idx = loadIndex(repoPath);
|
|
25
|
+
const indexed = new Set();
|
|
26
|
+
for (const e of Object.values(idx.entries))
|
|
27
|
+
indexed.add(e.relativePath);
|
|
28
|
+
const rawRoot = join(repoPath, "raw_sessions");
|
|
29
|
+
const onDisk = walkMd(rawRoot, repoPath);
|
|
30
|
+
const orphans = onDisk.filter((rel) => !indexed.has(rel));
|
|
31
|
+
const deleted = [];
|
|
32
|
+
if (orphans.length === 0) {
|
|
33
|
+
console.log(chalk.green(`✓ no orphan .md files in ${rawRoot}`));
|
|
34
|
+
console.log(chalk.gray(` scanned ${onDisk.length}, indexed ${indexed.size}`));
|
|
35
|
+
return { scanned: onDisk.length, indexed: indexed.size, orphans, deleted };
|
|
36
|
+
}
|
|
37
|
+
console.log(chalk.yellow(`found ${orphans.length} orphan .md file(s) (on disk but not in index):`));
|
|
38
|
+
for (const o of orphans)
|
|
39
|
+
console.log(` ${o}`);
|
|
40
|
+
console.log();
|
|
41
|
+
if (!apply) {
|
|
42
|
+
console.log(chalk.cyan(`dry-run — pass --apply to delete`));
|
|
43
|
+
return { scanned: onDisk.length, indexed: indexed.size, orphans, deleted };
|
|
44
|
+
}
|
|
45
|
+
// Apply: delete each orphan + sweep its parent dir if empty
|
|
46
|
+
const dirsTouched = new Set();
|
|
47
|
+
for (const o of orphans) {
|
|
48
|
+
const abs = join(repoPath, o);
|
|
49
|
+
try {
|
|
50
|
+
unlinkSync(abs);
|
|
51
|
+
deleted.push(o);
|
|
52
|
+
dirsTouched.add(dirname(abs));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
console.log(chalk.red(`! could not delete ${o}: ${err.message}`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Sweep empty dirs upward from each touched dir until we hit a non-empty
|
|
59
|
+
// ancestor or escape rawRoot. We stop short of rawRoot itself.
|
|
60
|
+
for (const d of dirsTouched) {
|
|
61
|
+
let cur = d;
|
|
62
|
+
while (cur.startsWith(rawRoot) && cur !== rawRoot) {
|
|
63
|
+
let entries = [];
|
|
64
|
+
try {
|
|
65
|
+
entries = readdirSync(cur);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
if (entries.length > 0)
|
|
71
|
+
break;
|
|
72
|
+
try {
|
|
73
|
+
rmdirSync(cur);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
cur = dirname(cur);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
console.log(chalk.green(`✓ deleted ${deleted.length} orphan(s)`));
|
|
82
|
+
return { scanned: onDisk.length, indexed: indexed.size, orphans, deleted };
|
|
83
|
+
}
|
|
84
|
+
function walkMd(dir, repoRoot) {
|
|
85
|
+
const out = [];
|
|
86
|
+
const stack = [dir];
|
|
87
|
+
while (stack.length > 0) {
|
|
88
|
+
const cur = stack.pop();
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = readdirSync(cur, { withFileTypes: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const p = join(cur, e.name);
|
|
98
|
+
if (e.isDirectory())
|
|
99
|
+
stack.push(p);
|
|
100
|
+
else if (e.isFile() && e.name.endsWith(".md")) {
|
|
101
|
+
out.push(relative(repoRoot, p));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Sanity: skip empty if rawRoot doesn't exist
|
|
106
|
+
void statSync;
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readConfig, writeConfig } from "../../config.js";
|
|
2
|
+
import { sanitizeBranchName } from "../../device.js";
|
|
3
|
+
/** Parse "FROM=TO" and add it to ~/.memarium/config.json's pathMap. Throws
|
|
4
|
+
* on malformed input. Used by the `memarium config --map-path` CLI flag.
|
|
5
|
+
*
|
|
6
|
+
* The pathMap field is optional in the Config schema; if it doesn't exist
|
|
7
|
+
* yet, this initializes it.
|
|
8
|
+
*/
|
|
9
|
+
export function setMapPath(spec) {
|
|
10
|
+
const idx = spec.indexOf("=");
|
|
11
|
+
if (idx < 0) {
|
|
12
|
+
throw new Error(`Invalid --map-path '${spec}': expected FROM=TO form`);
|
|
13
|
+
}
|
|
14
|
+
const from = spec.slice(0, idx);
|
|
15
|
+
const to = spec.slice(idx + 1);
|
|
16
|
+
if (!from)
|
|
17
|
+
throw new Error(`--map-path '${spec}': FROM is empty`);
|
|
18
|
+
if (!to)
|
|
19
|
+
throw new Error(`--map-path '${spec}': TO is empty`);
|
|
20
|
+
const cfg = readConfig();
|
|
21
|
+
const map = { ...(cfg.pathMap ?? {}), [from]: to };
|
|
22
|
+
writeConfig({ ...cfg, pathMap: map });
|
|
23
|
+
}
|
|
24
|
+
/** Set ~/.memarium/config.json's deviceBranch. Used by `memarium config
|
|
25
|
+
* --device <name>` so existing users can replace a drift-prone hostname
|
|
26
|
+
* (e.g. "Mac-mini-2.local") with a stable physical-label name (e.g. "mini2").
|
|
27
|
+
* Returns the previous and new branch names so the CLI can hint the user
|
|
28
|
+
* to delete the old remote branch.
|
|
29
|
+
*/
|
|
30
|
+
export function setDeviceBranch(name) {
|
|
31
|
+
const sanitized = sanitizeBranchName(name);
|
|
32
|
+
if (!sanitized)
|
|
33
|
+
throw new Error(`--device '${name}': sanitizes to empty branch name`);
|
|
34
|
+
const cfg = readConfig();
|
|
35
|
+
const previous = cfg.deviceBranch;
|
|
36
|
+
writeConfig({ ...cfg, deviceBranch: sanitized });
|
|
37
|
+
return { previous, current: sanitized };
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match index entries by any of: full sessionId UUID, 8-char shortId, or any
|
|
3
|
+
* UUID prefix. Case-insensitive. Returns all matches (could be 0, 1, or many).
|
|
4
|
+
*
|
|
5
|
+
* The caller (resume.ts) decides whether to:
|
|
6
|
+
* - 0 matches → throw "no session found"
|
|
7
|
+
* - 1 match → proceed
|
|
8
|
+
* - >1 matches → throw with candidate list, ask user for more specificity
|
|
9
|
+
*/
|
|
10
|
+
export function findEntries(idx, idOrPrefix) {
|
|
11
|
+
const needle = idOrPrefix.toLowerCase();
|
|
12
|
+
return Object.values(idx.entries).filter((e) => e.sessionId.toLowerCase().startsWith(needle));
|
|
13
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readConfig } from "../../config.js";
|
|
2
|
+
import { loadIndex } from "../../index-store.js";
|
|
3
|
+
import { loadAggregatedIndex } from "../../aggregated-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Returns sessions from BOTH the device's own spool index AND the union
|
|
6
|
+
* `.memarium/index.aggregated.json` written by CI to main. Dedupes by
|
|
7
|
+
* `tool:sessionId` — when the same session appears in both, the own copy
|
|
8
|
+
* wins (it carries the latest local sourceSha256 / mtime).
|
|
9
|
+
*
|
|
10
|
+
* Filtered + sorted newest-first. Caller (CLI handler) prints as a table.
|
|
11
|
+
*/
|
|
12
|
+
export async function listSessionsCmd(opts) {
|
|
13
|
+
const cfg = readConfig();
|
|
14
|
+
const ownIdx = loadIndex(cfg.repoPath);
|
|
15
|
+
const aggIdx = loadAggregatedIndex();
|
|
16
|
+
const merged = new Map();
|
|
17
|
+
for (const e of Object.values(ownIdx.entries)) {
|
|
18
|
+
merged.set(`${e.tool}:${e.sessionId}`, { ...e, isOwn: true });
|
|
19
|
+
}
|
|
20
|
+
for (const e of Object.values(aggIdx?.entries ?? {})) {
|
|
21
|
+
const k = `${e.tool}:${e.sessionId}`;
|
|
22
|
+
if (!merged.has(k))
|
|
23
|
+
merged.set(k, { ...e, isOwn: false });
|
|
24
|
+
}
|
|
25
|
+
const cutoffMs = parseSince(opts.since);
|
|
26
|
+
const result = [];
|
|
27
|
+
for (const entry of merged.values()) {
|
|
28
|
+
if (opts.project && entry.project !== opts.project)
|
|
29
|
+
continue;
|
|
30
|
+
if (cutoffMs !== null && Date.parse(entry.endedAt) < cutoffMs)
|
|
31
|
+
continue;
|
|
32
|
+
if (opts.device) {
|
|
33
|
+
const entryDevice = entry.originDevice;
|
|
34
|
+
const isOwnFromThisDevice = entry.isOwn && cfg.deviceBranch === opts.device;
|
|
35
|
+
if (!isOwnFromThisDevice && entryDevice !== opts.device)
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
result.push(entry);
|
|
39
|
+
}
|
|
40
|
+
result.sort((a, b) => b.endedAt.localeCompare(a.endedAt));
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
/** Parse "7d" / "1d" / "30d" / "24h" / "4w" → cutoff timestamp in ms; null if no --since set. */
|
|
44
|
+
function parseSince(since) {
|
|
45
|
+
if (!since)
|
|
46
|
+
return null;
|
|
47
|
+
const m = since.match(/^(\d+)([dhw])$/);
|
|
48
|
+
if (!m)
|
|
49
|
+
throw new Error(`Invalid --since '${since}'. Use NdHw form: 1d, 24h, 4w.`);
|
|
50
|
+
const n = parseInt(m[1], 10);
|
|
51
|
+
const unit = m[2];
|
|
52
|
+
const ms = unit === "d" ? 86400_000 : unit === "h" ? 3600_000 : 7 * 86400_000;
|
|
53
|
+
return Date.now() - n * ms;
|
|
54
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** macOS default ARG_MAX is 1 MB total args; Linux often higher. We use a
|
|
5
|
+
* conservative cap matching the smaller platform, leaving 10% headroom.
|
|
6
|
+
* The full prompt body + framing must fit under this; otherwise we fall
|
|
7
|
+
* back to writing the prompt to /tmp and asking Claude to Read it. */
|
|
8
|
+
export const ARG_MAX_BYTES = 256 * 1024;
|
|
9
|
+
/** Extract the header section of a 0.7+ manifest_version:1 md — the
|
|
10
|
+
* YAML frontmatter (with the manifest fields) plus the `# Table of
|
|
11
|
+
* Contents` block. Returns null when the md predates manifest_version:1
|
|
12
|
+
* (no `manifest_version: 1` line in the first 200 lines), so the caller
|
|
13
|
+
* can fall back to the full-embed path.
|
|
14
|
+
*
|
|
15
|
+
* The split point is the first `## User` or `## Assistant` heading at
|
|
16
|
+
* the start of a line, which always appears immediately after the TOC.
|
|
17
|
+
*/
|
|
18
|
+
export function extractMdHeader(contextMd) {
|
|
19
|
+
const lookahead = contextMd.slice(0, 50 * 1024);
|
|
20
|
+
if (!/^manifest_version:\s*1\b/m.test(lookahead))
|
|
21
|
+
return null;
|
|
22
|
+
const m = contextMd.match(/\n## (User|Assistant)/);
|
|
23
|
+
if (!m)
|
|
24
|
+
return null;
|
|
25
|
+
return contextMd.slice(0, m.index);
|
|
26
|
+
}
|
|
27
|
+
/** Build the user-prompt text we'll feed Claude as the first turn. Used
|
|
28
|
+
* for legacy (0.6) md without manifest_version:1, or as a fallback when
|
|
29
|
+
* the header extractor can't locate the split point. */
|
|
30
|
+
export function renderResumePrompt(entry, contextMd, ctx = {}) {
|
|
31
|
+
return [
|
|
32
|
+
`I had a coding session on another machine that I'd like to continue.`,
|
|
33
|
+
`Below is the full conversation history. Read it carefully — pay`,
|
|
34
|
+
`attention to what files were touched, what was decided, and any open`,
|
|
35
|
+
`questions or TODOs at the end. Then summarize back to me what state`,
|
|
36
|
+
`we're in, and ask me what I'd like to do next.`,
|
|
37
|
+
``,
|
|
38
|
+
`---`,
|
|
39
|
+
`Session: ${entry.displayName}`,
|
|
40
|
+
`Source device: ${ctx.device ?? "(unknown)"}`,
|
|
41
|
+
`Started: ${entry.startedAt}`,
|
|
42
|
+
`Ended: ${entry.endedAt}`,
|
|
43
|
+
`---`,
|
|
44
|
+
``,
|
|
45
|
+
contextMd,
|
|
46
|
+
``,
|
|
47
|
+
`---`,
|
|
48
|
+
`End of prior session. What's our next step?`,
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
/** Chunked-resume prompt for 0.7+ manifest_version:1 md. Embeds only the
|
|
52
|
+
* header (frontmatter + manifest + TOC) inline and points Claude at the
|
|
53
|
+
* on-disk md for body access. The resuming Claude orients via the
|
|
54
|
+
* manifest, then `Read offset:` jumps via the TOC's →L<line> column to
|
|
55
|
+
* pull only the segments it needs — letting us resume 100MB sessions
|
|
56
|
+
* without blowing the context window. */
|
|
57
|
+
export function renderResumePromptChunked(entry, mdPath, headerMd, fullMdBytes, ctx = {}) {
|
|
58
|
+
const sizeStr = formatSize(fullMdBytes);
|
|
59
|
+
return [
|
|
60
|
+
`I had a coding session on another machine that I'd like to continue.`,
|
|
61
|
+
`The full transcript lives on disk at:`,
|
|
62
|
+
``,
|
|
63
|
+
` ${mdPath}`,
|
|
64
|
+
``,
|
|
65
|
+
`It is ${sizeStr} — large enough that you should NOT Read the whole`,
|
|
66
|
+
`file at once. The header below has a manifest (mechanical facts about`,
|
|
67
|
+
`what was worked on) and a Table of Contents. Use them to navigate:`,
|
|
68
|
+
``,
|
|
69
|
+
`1. Skim the manifest fields (commits / files_touched / candidate_decisions)`,
|
|
70
|
+
` to understand the session's shape.`,
|
|
71
|
+
`2. Pick 3–5 TOC rows most relevant to where the work left off (commits`,
|
|
72
|
+
` near the end, decisions, last few user turns).`,
|
|
73
|
+
`3. The TOC's "→L<number>" column is the absolute line number in the`,
|
|
74
|
+
` file. Use the Read tool with offset:<number> and limit:200 to pull`,
|
|
75
|
+
` just that turn.`,
|
|
76
|
+
`4. Summarize back to me what state we're in (last decisions, open`,
|
|
77
|
+
` questions, next_steps), then ask what to do next.`,
|
|
78
|
+
``,
|
|
79
|
+
`---`,
|
|
80
|
+
`Session: ${entry.displayName}`,
|
|
81
|
+
`Source device: ${ctx.device ?? "(unknown)"}`,
|
|
82
|
+
`Started: ${entry.startedAt}`,
|
|
83
|
+
`Ended: ${entry.endedAt}`,
|
|
84
|
+
`---`,
|
|
85
|
+
``,
|
|
86
|
+
headerMd,
|
|
87
|
+
``,
|
|
88
|
+
`---`,
|
|
89
|
+
`End of header. Body continues in the on-disk file above (line ~${headerMd.split("\n").length + 2} onward).`,
|
|
90
|
+
`What's our next step?`,
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
/** Decide how to pass the prompt to claude. Short prompts go via argv
|
|
94
|
+
* (`claude "prompt"`); long ones get spilled to /tmp and Claude reads
|
|
95
|
+
* them with its Read tool. The threshold leaves 10% headroom under
|
|
96
|
+
* ARG_MAX_BYTES so other argv components have room. */
|
|
97
|
+
export function chooseInvocation(prompt, shortId) {
|
|
98
|
+
if (Buffer.byteLength(prompt, "utf8") < ARG_MAX_BYTES * 0.9) {
|
|
99
|
+
return ["claude", prompt];
|
|
100
|
+
}
|
|
101
|
+
const tmpPath = join(tmpdir(), `.memarium-resume-${shortId}.md`);
|
|
102
|
+
writeFileSync(tmpPath, prompt, "utf8");
|
|
103
|
+
return ["claude", `Read ${tmpPath} and act on the instructions there.`];
|
|
104
|
+
}
|
|
105
|
+
/** 0.8.5: below this, resume uses full-embed mode (the whole md goes
|
|
106
|
+
* inline). Above it, chunked mode (header inline + on-disk Read).
|
|
107
|
+
* 5000 tokens-ish — fits comfortably even in 200K context models,
|
|
108
|
+
* and saves Claude 1-2 round-trips on small sessions where chunked
|
|
109
|
+
* navigation buys nothing. */
|
|
110
|
+
export const CHUNKED_THRESHOLD_BYTES = 50 * 1024;
|
|
111
|
+
/** Adaptive byte → human size. Sub-MB shows KB; otherwise MB with one
|
|
112
|
+
* decimal. Pre-0.8.5 we always printed MB.toFixed(1), which rendered
|
|
113
|
+
* 33 KB as `"0.0 MB"` — technically true, totally misleading next to
|
|
114
|
+
* "large enough that you should NOT Read the whole file at once". */
|
|
115
|
+
function formatSize(bytes) {
|
|
116
|
+
if (bytes < 1024)
|
|
117
|
+
return `${bytes} B`;
|
|
118
|
+
if (bytes < 1024 * 1024)
|
|
119
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
120
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { readConfig } from "../../config.js";
|
|
6
|
+
import { loadIndex } from "../../index-store.js";
|
|
7
|
+
import { loadAggregatedIndex, aggregatedPath } from "../../aggregated-store.js";
|
|
8
|
+
import { findEntries } from "./fuzzy-match.js";
|
|
9
|
+
import { renderResumePrompt, renderResumePromptChunked, extractMdHeader, chooseInvocation, CHUNKED_THRESHOLD_BYTES, } from "./render-prompt.js";
|
|
10
|
+
/**
|
|
11
|
+
* `memarium resume <id>` — find the source session's markdown, build a prompt
|
|
12
|
+
* with the conversation history, and launch a fresh `claude` session with
|
|
13
|
+
* that prompt as the first user turn. The new Claude reads the prior context
|
|
14
|
+
* and asks the user what to continue with.
|
|
15
|
+
*
|
|
16
|
+
* Does NOT touch ~/.claude/projects/, does NOT call `claude --resume`, does
|
|
17
|
+
* NOT update any Claude internal state. Uses only the public `claude [prompt]`
|
|
18
|
+
* CLI interface.
|
|
19
|
+
*/
|
|
20
|
+
export async function resumeCmd(opts) {
|
|
21
|
+
const cfg = readConfig();
|
|
22
|
+
const ownIdx = loadIndex(cfg.repoPath);
|
|
23
|
+
const aggIdx = loadAggregatedIndex();
|
|
24
|
+
const ownMatches = findEntries(ownIdx, opts.idOrPrefix).map((entry) => ({ entry, isOwn: true }));
|
|
25
|
+
const aggMatches = aggIdx
|
|
26
|
+
? findEntries(aggIdx, opts.idOrPrefix)
|
|
27
|
+
.filter((e) => !ownIdx.entries[`${e.tool}:${e.sessionId}`]) // dedupe — own wins
|
|
28
|
+
.map((entry) => ({ entry, isOwn: false }))
|
|
29
|
+
: [];
|
|
30
|
+
const matches = [...ownMatches, ...aggMatches];
|
|
31
|
+
if (matches.length === 0) {
|
|
32
|
+
throw new Error(`No session matches '${opts.idOrPrefix}'. ` +
|
|
33
|
+
`Run 'memarium list-sessions' to see what's available.`);
|
|
34
|
+
}
|
|
35
|
+
if (matches.length > 1) {
|
|
36
|
+
const lines = [
|
|
37
|
+
`Multiple matches for '${opts.idOrPrefix}':`,
|
|
38
|
+
...matches.map(({ entry: m, isOwn }) => ` ${m.shortId} ${m.displayName.slice(0, 50)} (${m.startedAt.slice(0, 10)})${isOwn ? "" : " [from another device]"}`),
|
|
39
|
+
``,
|
|
40
|
+
`Pass a longer id prefix to disambiguate.`,
|
|
41
|
+
];
|
|
42
|
+
throw new Error(lines.join("\n"));
|
|
43
|
+
}
|
|
44
|
+
const { entry, isOwn } = matches[0];
|
|
45
|
+
// 2. Validate cwd (after pathMap translation)
|
|
46
|
+
const pathMap = cfg.pathMap ?? {};
|
|
47
|
+
const expectedCwd = applyPathMap(entry.projectRaw, pathMap);
|
|
48
|
+
const actualCwd = resolve(opts.cwd ?? process.cwd());
|
|
49
|
+
if (actualCwd !== expectedCwd) {
|
|
50
|
+
throw new Error(`This session was for ${expectedCwd}\n` +
|
|
51
|
+
`cd there first: cd ${expectedCwd}`);
|
|
52
|
+
}
|
|
53
|
+
// 3. Locate context md (handle legacy .raw.json relativePath by falling back to .md)
|
|
54
|
+
const mdRelative = entry.relativePath.replace(/\.raw\.json$/, ".md");
|
|
55
|
+
const baseRoot = isOwn ? cfg.repoPath : aggregatedPath();
|
|
56
|
+
const mdPath = join(baseRoot, mdRelative);
|
|
57
|
+
if (!existsSync(mdPath)) {
|
|
58
|
+
throw new Error(`Context md missing: ${mdPath}. ` +
|
|
59
|
+
(isOwn
|
|
60
|
+
? `The source device may not have synced this session yet, or you're on a 0.5.x spool that hasn't been re-synced under 0.6.`
|
|
61
|
+
: `This is a sibling-device session — run \`memarium sync\` to refresh the aggregated worktree.`));
|
|
62
|
+
}
|
|
63
|
+
const contextMd = readFileSync(mdPath, "utf8");
|
|
64
|
+
const mdBytes = statSync(mdPath).size;
|
|
65
|
+
// 4. Build prompt. Chunked mode (header inline + on-disk Read) only
|
|
66
|
+
// when BOTH conditions hold:
|
|
67
|
+
// (a) md has `manifest_version: 1` (0.7.0+ schema), so the TOC
|
|
68
|
+
// is available for Claude to navigate, AND
|
|
69
|
+
// (b) file is bigger than CHUNKED_THRESHOLD_BYTES (50 KB). Below
|
|
70
|
+
// that, chunked navigation is overhead — full embed is faster
|
|
71
|
+
// and gives Claude the whole conversation in one prompt. 0.8.5
|
|
72
|
+
// dogfood caught a 33 KB cross-device session getting chunked
|
|
73
|
+
// unnecessarily.
|
|
74
|
+
// Otherwise (no manifest, or small file) fall back to embedding the
|
|
75
|
+
// full md.
|
|
76
|
+
const headerMd = extractMdHeader(contextMd);
|
|
77
|
+
const useChunked = headerMd !== null && mdBytes >= CHUNKED_THRESHOLD_BYTES;
|
|
78
|
+
const prompt = useChunked
|
|
79
|
+
? renderResumePromptChunked(entry, mdPath, headerMd, mdBytes)
|
|
80
|
+
: renderResumePrompt(entry, contextMd);
|
|
81
|
+
const argv = chooseInvocation(prompt, entry.shortId);
|
|
82
|
+
// 5. Print or spawn
|
|
83
|
+
if (opts.print) {
|
|
84
|
+
console.log(`To continue, run:`);
|
|
85
|
+
console.log(` cd ${expectedCwd}`);
|
|
86
|
+
console.log(` ${argv.map(shellQuote).join(" ")}`);
|
|
87
|
+
return { matchedSessionId: entry.sessionId, expectedCwd, mdPath, invocation: argv, spawned: false };
|
|
88
|
+
}
|
|
89
|
+
console.log(chalk.green(`\n✓ Matched: "${entry.displayName}"`));
|
|
90
|
+
console.log(chalk.gray(` Session id: ${entry.sessionId}`));
|
|
91
|
+
console.log(chalk.gray(` Started: ${entry.startedAt}`));
|
|
92
|
+
console.log(chalk.gray(` Context: ${(mdBytes / 1024).toFixed(1)} KB ${useChunked ? "(chunked — header inline, body on disk)" : "(full embed)"}`));
|
|
93
|
+
console.log(chalk.cyan(`\nLaunching claude with context as first prompt...\n`));
|
|
94
|
+
const r = spawnSync(argv[0], argv.slice(1), {
|
|
95
|
+
cwd: expectedCwd,
|
|
96
|
+
stdio: "inherit",
|
|
97
|
+
});
|
|
98
|
+
if (r.error) {
|
|
99
|
+
throw new Error(`Failed to spawn 'claude': ${r.error.message}. ` +
|
|
100
|
+
`Make sure Claude Code is installed and on your PATH.`);
|
|
101
|
+
}
|
|
102
|
+
return { matchedSessionId: entry.sessionId, expectedCwd, mdPath, invocation: argv, spawned: true };
|
|
103
|
+
}
|
|
104
|
+
/** Apply longest-prefix-wins path translation to a single path string. */
|
|
105
|
+
function applyPathMap(path, pathMap) {
|
|
106
|
+
const entries = Object.entries(pathMap).sort(([a], [b]) => b.length - a.length);
|
|
107
|
+
for (const [from, to] of entries) {
|
|
108
|
+
if (path === from)
|
|
109
|
+
return to;
|
|
110
|
+
if (path.startsWith(from + "/"))
|
|
111
|
+
return to + path.slice(from.length);
|
|
112
|
+
}
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
/** Minimal shell-quoting for the --print output. Wraps in single quotes
|
|
116
|
+
* and escapes embedded single quotes via the standard close-quote dance. */
|
|
117
|
+
function shellQuote(s) {
|
|
118
|
+
if (/^[A-Za-z0-9_/.,@:=+-]+$/.test(s))
|
|
119
|
+
return s;
|
|
120
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { readConfig } from "../config.js";
|
|
5
|
+
import { loadIndex } from "../index-store.js";
|
|
6
|
+
export async function showCmd(ref) {
|
|
7
|
+
const cfg = readConfig();
|
|
8
|
+
const idx = loadIndex(cfg.repoPath);
|
|
9
|
+
const entries = Object.values(idx.entries);
|
|
10
|
+
const hit = entries.find((e) => e.sessionId === ref ||
|
|
11
|
+
e.shortId === ref ||
|
|
12
|
+
e.nameSlug === ref ||
|
|
13
|
+
e.displayName === ref);
|
|
14
|
+
if (!hit) {
|
|
15
|
+
console.log(chalk.red(`no session matching "${ref}"`));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const mdRel = hit.relativePath.replace(/\.raw\.json$/, ".md");
|
|
19
|
+
const abs = join(cfg.repoPath, mdRel);
|
|
20
|
+
process.stdout.write(readFileSync(abs).toString("utf8"));
|
|
21
|
+
}
|