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,87 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal readline-promise wrapper. Caller closes the rl interface via
|
|
4
|
+
* `closePrompts()` once all questions are asked. Helpers below all use the
|
|
5
|
+
* SAME shared rl so they stay synchronous-feeling for the user.
|
|
6
|
+
*/
|
|
7
|
+
let _rl;
|
|
8
|
+
function rl() {
|
|
9
|
+
if (!_rl)
|
|
10
|
+
_rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
return _rl;
|
|
12
|
+
}
|
|
13
|
+
export function closePrompts() {
|
|
14
|
+
if (_rl) {
|
|
15
|
+
_rl.close();
|
|
16
|
+
_rl = undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Free-text input. Returns empty string on EOF / Ctrl-D. */
|
|
20
|
+
export async function prompt(question, defaultValue) {
|
|
21
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
22
|
+
const ans = (await rl().question(`${question}${suffix}: `)).trim();
|
|
23
|
+
return ans || defaultValue || "";
|
|
24
|
+
}
|
|
25
|
+
/** y/n → true/false. Default applies on empty input. */
|
|
26
|
+
export async function promptYesNo(question, defaultYes = false) {
|
|
27
|
+
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
28
|
+
for (;;) {
|
|
29
|
+
const ans = (await rl().question(`${question} ${hint}: `)).trim().toLowerCase();
|
|
30
|
+
if (!ans)
|
|
31
|
+
return defaultYes;
|
|
32
|
+
if (ans === "y" || ans === "yes")
|
|
33
|
+
return true;
|
|
34
|
+
if (ans === "n" || ans === "no")
|
|
35
|
+
return false;
|
|
36
|
+
console.log(` please answer y or n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Pick from labeled options. Returns the value of the chosen option. */
|
|
40
|
+
export async function promptChoice(question, options, defaultIndex = 0) {
|
|
41
|
+
console.log(question);
|
|
42
|
+
for (let i = 0; i < options.length; i++) {
|
|
43
|
+
const o = options[i];
|
|
44
|
+
const marker = i === defaultIndex ? "*" : " ";
|
|
45
|
+
const desc = o.description ? ` — ${o.description}` : "";
|
|
46
|
+
console.log(` ${marker} ${i + 1}) ${o.label}${desc}`);
|
|
47
|
+
}
|
|
48
|
+
for (;;) {
|
|
49
|
+
const raw = (await rl().question(`Choose [1-${options.length}, default ${defaultIndex + 1}]: `)).trim();
|
|
50
|
+
if (!raw)
|
|
51
|
+
return options[defaultIndex].value;
|
|
52
|
+
const n = Number(raw);
|
|
53
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length)
|
|
54
|
+
return options[n - 1].value;
|
|
55
|
+
console.log(` please enter a number 1-${options.length}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Hidden input (passphrase). Disables echo by writing the question, then
|
|
60
|
+
* temporarily silencing stdout writes from readline echo via a write-shim.
|
|
61
|
+
* Falls back to plain prompt if stdin isn't a TTY.
|
|
62
|
+
*/
|
|
63
|
+
export async function promptHidden(question) {
|
|
64
|
+
if (!process.stdin.isTTY)
|
|
65
|
+
return prompt(question);
|
|
66
|
+
const r = rl();
|
|
67
|
+
// Hack: monkey-patch _writeToOutput so each keystroke writes "*".
|
|
68
|
+
// This is the standard Node.js trick for hidden input via readline.
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
const anyR = r;
|
|
71
|
+
const origWrite = anyR._writeToOutput?.bind(anyR);
|
|
72
|
+
anyR._writeToOutput = (s) => {
|
|
73
|
+
if (s.includes(question))
|
|
74
|
+
origWrite(s);
|
|
75
|
+
else
|
|
76
|
+
origWrite("*".repeat(s.length));
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
const ans = await r.question(`${question}: `);
|
|
80
|
+
process.stdout.write("\n");
|
|
81
|
+
return ans;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
if (origWrite)
|
|
85
|
+
anyR._writeToOutput = origWrite;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-repo data directory used by memarium to store its index and book
|
|
3
|
+
* index. The project has been renamed twice: `.memvc/` → `.vibebook/` →
|
|
4
|
+
* `.memarium/`. A one-shot migration in `migrateLegacyDataDir` renames the
|
|
5
|
+
* newest legacy dir it finds (`.vibebook/`, else `.memvc/`) → `.memarium/`
|
|
6
|
+
* on first sync/digest run.
|
|
7
|
+
*
|
|
8
|
+
* Use these helpers (not raw string literals) anywhere a path inside this
|
|
9
|
+
* directory is needed, so the next rename is a one-line change.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
export const REPO_DATA_DIR = ".memarium";
|
|
13
|
+
/** Legacy in-repo data dirs to migrate FROM, newest first. */
|
|
14
|
+
export const LEGACY_REPO_DATA_DIRS = [".vibebook", ".memvc"];
|
|
15
|
+
export const INDEX_REL = `${REPO_DATA_DIR}/index.json`;
|
|
16
|
+
export const BOOK_INDEX_REL = `${REPO_DATA_DIR}/index.book.json`;
|
|
17
|
+
export function dataDirAbs(repoPath) {
|
|
18
|
+
return join(repoPath, REPO_DATA_DIR);
|
|
19
|
+
}
|
|
20
|
+
export function indexAbs(repoPath) {
|
|
21
|
+
return join(repoPath, INDEX_REL);
|
|
22
|
+
}
|
|
23
|
+
export function bookIndexAbs(repoPath) {
|
|
24
|
+
return join(repoPath, BOOK_INDEX_REL);
|
|
25
|
+
}
|
package/dist/src/slug.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const UNSAFE = /[\\/:*?"<>|\s.,;!()[\]{}@#$%^&+=`~]+/g;
|
|
2
|
+
export function deriveSlug(firstUserMessage) {
|
|
3
|
+
const collapsed = firstUserMessage.trim().replace(/\s+/g, " ");
|
|
4
|
+
const display = collapsed.slice(0, 120) || "untitled";
|
|
5
|
+
let slug = collapsed.replace(UNSAFE, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
6
|
+
slug = slug.slice(0, 60);
|
|
7
|
+
if (!slug)
|
|
8
|
+
slug = "untitled";
|
|
9
|
+
return { slug, display };
|
|
10
|
+
}
|
|
11
|
+
export function toDisplayName(s) {
|
|
12
|
+
return s;
|
|
13
|
+
}
|
|
14
|
+
export function projectSlugFromPath(cwdOrPath) {
|
|
15
|
+
if (!cwdOrPath || cwdOrPath === "/")
|
|
16
|
+
return "root";
|
|
17
|
+
const parts = cwdOrPath.split("/").filter(Boolean);
|
|
18
|
+
if (parts.length === 0)
|
|
19
|
+
return "root";
|
|
20
|
+
if (parts.length === 1)
|
|
21
|
+
return parts[0];
|
|
22
|
+
// Prefer "parent-basename" so `/Users/me/edge/memvc` → "edge-memvc"
|
|
23
|
+
const last = parts[parts.length - 1];
|
|
24
|
+
const parent = parts[parts.length - 2];
|
|
25
|
+
if (parent === "Users" || parent === "home")
|
|
26
|
+
return "home";
|
|
27
|
+
return `${parent}-${last}`;
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, basename } from "node:path";
|
|
5
|
+
import { deriveSlug } from "../slug.js";
|
|
6
|
+
import { cachedProjectSlug } from "../project-identity.js";
|
|
7
|
+
import { inferProjectFromContent, listKnownProjectRoots, MIN_CONFIDENCE, } from "../content-project-inference.js";
|
|
8
|
+
// Cached at module scope: listing ~/.claude/projects/ on every parse is
|
|
9
|
+
// fine but redundant when sync touches hundreds of jsonls in one run.
|
|
10
|
+
let cachedRoots = null;
|
|
11
|
+
function getRoots() {
|
|
12
|
+
if (cachedRoots === null)
|
|
13
|
+
cachedRoots = listKnownProjectRoots();
|
|
14
|
+
return cachedRoots;
|
|
15
|
+
}
|
|
16
|
+
export class ClaudeCodeAdapter {
|
|
17
|
+
root;
|
|
18
|
+
name = "claude";
|
|
19
|
+
constructor(root = join(homedir(), ".claude", "projects")) {
|
|
20
|
+
this.root = root;
|
|
21
|
+
}
|
|
22
|
+
async *discover() {
|
|
23
|
+
if (!existsSync(this.root))
|
|
24
|
+
return;
|
|
25
|
+
const stack = [this.root];
|
|
26
|
+
while (stack.length) {
|
|
27
|
+
const dir = stack.pop();
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
const p = join(dir, e.name);
|
|
37
|
+
if (e.isDirectory()) {
|
|
38
|
+
// Skip our own scratch dirs and system tmpdirs — see isMemariumOrTmpProjectDir.
|
|
39
|
+
// We only filter at the top level (entries directly under ~/.claude/projects/).
|
|
40
|
+
if (dir === this.root && isMemariumOrTmpProjectDir(e.name))
|
|
41
|
+
continue;
|
|
42
|
+
// Skip Claude Code's own subagent transcript dirs at any depth.
|
|
43
|
+
// These appear as ~/.claude/projects/<proj>/<sessionId>/subagents/agent-*.jsonl
|
|
44
|
+
// and contain agentic prompt boilerplate ("You are implementing Task X")
|
|
45
|
+
// that pollutes raw_sessions with bogus session titles. They are NOT user
|
|
46
|
+
// sessions; they are sub-task transcripts spawned by an outer session.
|
|
47
|
+
if (e.name === "subagents")
|
|
48
|
+
continue;
|
|
49
|
+
stack.push(p);
|
|
50
|
+
}
|
|
51
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
|
52
|
+
const st = statSync(p);
|
|
53
|
+
const buf = readFileSync(p);
|
|
54
|
+
const sha = createHash("sha256").update(buf).digest("hex");
|
|
55
|
+
yield {
|
|
56
|
+
sourcePath: p,
|
|
57
|
+
sourceMtimeMs: st.mtimeMs,
|
|
58
|
+
sourceSha256: sha,
|
|
59
|
+
load: async () => parseClaudeJsonl(p, buf.toString("utf8")),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Skip Claude project directories that correspond to memarium's own scratch
|
|
68
|
+
* subprocesses. We deliberately do NOT filter by tmpdir-prefix alone —
|
|
69
|
+
* developers may legitimately run `claude` in /tmp/experiment etc., and we
|
|
70
|
+
* shouldn't silently drop their work. We require one of the known scratch
|
|
71
|
+
* substrings (which only memarium-spawned cwds contain) to confirm provenance.
|
|
72
|
+
*
|
|
73
|
+
* Both `memarium-claude-` and `memvc-claude-` are recognized — the latter is
|
|
74
|
+
* the legacy name from before the project was renamed; old user machines
|
|
75
|
+
* may still have leftover dirs from pre-rename runs that crashed before
|
|
76
|
+
* cleanup, and we want sync to skip them too.
|
|
77
|
+
*/
|
|
78
|
+
export function isMemariumOrTmpProjectDir(name) {
|
|
79
|
+
return name.includes("-memarium-claude-") || name.includes("-memvc-claude-");
|
|
80
|
+
}
|
|
81
|
+
function parseClaudeJsonl(sourcePath, content) {
|
|
82
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
83
|
+
const messages = [];
|
|
84
|
+
let sessionId = "";
|
|
85
|
+
let cwd = "";
|
|
86
|
+
let startedAt = "";
|
|
87
|
+
let endedAt = "";
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
let obj;
|
|
90
|
+
try {
|
|
91
|
+
obj = JSON.parse(line);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (obj.sessionId && !sessionId)
|
|
97
|
+
sessionId = obj.sessionId;
|
|
98
|
+
if (obj.cwd && !cwd)
|
|
99
|
+
cwd = obj.cwd;
|
|
100
|
+
if (obj.type === "user" || obj.type === "assistant") {
|
|
101
|
+
// isMeta=true entries are system-injected pseudo-messages (slash-command
|
|
102
|
+
// skill body, command output replays, etc.) — never real user input.
|
|
103
|
+
// Without this filter, a session that started with `hi` + `/memarium`
|
|
104
|
+
// (both too short to survive sanitization) would derive its displayName
|
|
105
|
+
// from the injected skill template, producing titles like
|
|
106
|
+
// "## Step 0 — Detect the mode (DO THIS FIRST)…" with no real user prompts
|
|
107
|
+
// in the rendered .md.
|
|
108
|
+
if (obj.isMeta === true)
|
|
109
|
+
continue;
|
|
110
|
+
const ts = typeof obj.timestamp === "string" ? obj.timestamp : undefined;
|
|
111
|
+
if (ts) {
|
|
112
|
+
if (!startedAt)
|
|
113
|
+
startedAt = ts;
|
|
114
|
+
endedAt = ts;
|
|
115
|
+
}
|
|
116
|
+
const { text: rawText, reasoning: rawReasoning, contentBlocks } = extractParts(obj.message);
|
|
117
|
+
const text = sanitizeMessageText(rawText);
|
|
118
|
+
const reasoning = sanitizeMessageText(rawReasoning);
|
|
119
|
+
// Drop the message only when text + reasoning + tool blocks are all
|
|
120
|
+
// empty. A message that's "just a tool_use" still carries information
|
|
121
|
+
// and should be kept.
|
|
122
|
+
const hasToolBlocks = contentBlocks.some((b) => b.type === "tool_use" || b.type === "tool_result");
|
|
123
|
+
if (text || reasoning || hasToolBlocks) {
|
|
124
|
+
const msg = {
|
|
125
|
+
role: obj.type === "user" ? "user" : "assistant",
|
|
126
|
+
text,
|
|
127
|
+
timestamp: ts,
|
|
128
|
+
raw: obj,
|
|
129
|
+
contentBlocks,
|
|
130
|
+
};
|
|
131
|
+
if (reasoning)
|
|
132
|
+
msg.reasoning = reasoning;
|
|
133
|
+
messages.push(msg);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const firstUser = messages.find((m) => m.role === "user")?.text ?? "";
|
|
138
|
+
const { slug, display } = deriveSlug(firstUser);
|
|
139
|
+
const fallbackId = basename(sourcePath, ".jsonl");
|
|
140
|
+
const finalId = sessionId || fallbackId;
|
|
141
|
+
const shortId = finalId.slice(0, 8);
|
|
142
|
+
// Default project from cwd; override only if content inference is
|
|
143
|
+
// confident AND disagrees with cwd. We carry the original cwd-project
|
|
144
|
+
// in `cwdProject` for auditing — caller (prepare/digest) can show it.
|
|
145
|
+
const cwdProject = cachedProjectSlug(cwd);
|
|
146
|
+
const inference = inferProjectFromContent(messages, getRoots());
|
|
147
|
+
const useInferred = inference.inferredProject !== null &&
|
|
148
|
+
inference.inferredProject !== cwdProject &&
|
|
149
|
+
inference.confidence >= MIN_CONFIDENCE;
|
|
150
|
+
const project = useInferred ? inference.inferredProject : cwdProject;
|
|
151
|
+
const out = {
|
|
152
|
+
tool: "claude",
|
|
153
|
+
sessionId: finalId,
|
|
154
|
+
shortId,
|
|
155
|
+
project,
|
|
156
|
+
projectRaw: cwd,
|
|
157
|
+
startedAt: startedAt || new Date(0).toISOString(),
|
|
158
|
+
endedAt: endedAt || new Date(0).toISOString(),
|
|
159
|
+
nameSlug: slug,
|
|
160
|
+
displayName: display,
|
|
161
|
+
messages,
|
|
162
|
+
sourcePath,
|
|
163
|
+
};
|
|
164
|
+
if (useInferred) {
|
|
165
|
+
out.projectInferredFrom = "content";
|
|
166
|
+
out.cwdProject = cwdProject;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Pull text + reasoning out of a Claude API message.
|
|
172
|
+
*
|
|
173
|
+
* Content can be either a string or an array of typed blocks:
|
|
174
|
+
* - {type:"text", text:"..."} → text
|
|
175
|
+
* - {type:"thinking", thinking:"..."} → reasoning (usually empty in CLI
|
|
176
|
+
* output because the API returns an encrypted signature instead;
|
|
177
|
+
* rare cases ship plaintext when the user enabled it)
|
|
178
|
+
* - {type:"tool_use" / "tool_result"} → ignored (memarium doesn't
|
|
179
|
+
* summarize tool traces; logex does the same)
|
|
180
|
+
*/
|
|
181
|
+
function extractParts(message) {
|
|
182
|
+
if (!message)
|
|
183
|
+
return { text: "", reasoning: "", contentBlocks: [] };
|
|
184
|
+
const c = message.content;
|
|
185
|
+
if (typeof c === "string") {
|
|
186
|
+
return {
|
|
187
|
+
text: c,
|
|
188
|
+
reasoning: "",
|
|
189
|
+
contentBlocks: [{ type: "text", text: c }],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (!Array.isArray(c))
|
|
193
|
+
return { text: "", reasoning: "", contentBlocks: [] };
|
|
194
|
+
const texts = [];
|
|
195
|
+
const reasonings = [];
|
|
196
|
+
const blocks = [];
|
|
197
|
+
for (const p of c) {
|
|
198
|
+
if (!p || typeof p !== "object")
|
|
199
|
+
continue;
|
|
200
|
+
if (p.type === "text" && typeof p.text === "string") {
|
|
201
|
+
texts.push(p.text);
|
|
202
|
+
blocks.push({ type: "text", text: p.text });
|
|
203
|
+
}
|
|
204
|
+
else if (p.type === "thinking" && typeof p.thinking === "string" && p.thinking.length > 0) {
|
|
205
|
+
reasonings.push(p.thinking);
|
|
206
|
+
blocks.push({ type: "thinking", thinking: p.thinking });
|
|
207
|
+
}
|
|
208
|
+
else if (p.type === "tool_use" && typeof p.name === "string") {
|
|
209
|
+
const block = { type: "tool_use", name: p.name, input: p.input ?? {} };
|
|
210
|
+
if (typeof p.id === "string")
|
|
211
|
+
block.id = p.id;
|
|
212
|
+
blocks.push(block);
|
|
213
|
+
}
|
|
214
|
+
else if (p.type === "tool_result") {
|
|
215
|
+
// tool_result.content can be a string OR an array of {type:"text",text:"..."} blocks.
|
|
216
|
+
// Flatten to a single string for our markdown renderer.
|
|
217
|
+
let content = "";
|
|
218
|
+
if (typeof p.content === "string")
|
|
219
|
+
content = p.content;
|
|
220
|
+
else if (Array.isArray(p.content)) {
|
|
221
|
+
content = p.content
|
|
222
|
+
.map((part) => (typeof part?.text === "string" ? part.text : ""))
|
|
223
|
+
.join("");
|
|
224
|
+
}
|
|
225
|
+
const block = { type: "tool_result", content };
|
|
226
|
+
if (typeof p.tool_use_id === "string")
|
|
227
|
+
block.toolUseId = p.tool_use_id;
|
|
228
|
+
blocks.push(block);
|
|
229
|
+
}
|
|
230
|
+
// image / etc. → drop
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
text: texts.join("\n"),
|
|
234
|
+
reasoning: reasonings.join("\n"),
|
|
235
|
+
contentBlocks: blocks,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Strip noise that pollutes summarization quality. The patterns target Claude
|
|
240
|
+
* Code CLI's command-system markers and ANSI-laden tool output that show up
|
|
241
|
+
* inside the user's text content but carry zero information about the actual
|
|
242
|
+
* coding work.
|
|
243
|
+
*
|
|
244
|
+
* Categories handled:
|
|
245
|
+
* 1. Inline tag blocks: <system-reminder>...</system-reminder>,
|
|
246
|
+
* <local-command-caveat>...</local-command-caveat>,
|
|
247
|
+
* <command-message>, <command-name>, <command-args>,
|
|
248
|
+
* <local-command-stdout>...</local-command-stdout>
|
|
249
|
+
* (the stdout block can contain heavy ANSI escapes from /context, /model,
|
|
250
|
+
* /tasks etc. — strip whole block)
|
|
251
|
+
* 2. Skill preamble: every text starts with "Base directory for this skill:"
|
|
252
|
+
* followed by hundreds of lines of skill template. Drop everything from
|
|
253
|
+
* that marker to the next blank-line + "## " heading or end of text.
|
|
254
|
+
* 3. API error messages: "API Error: 400 ..." replies are noise.
|
|
255
|
+
* 4. Final length gate: after stripping, drop if < 10 chars (tiny
|
|
256
|
+
* acknowledgements like "ok" / "hi" carry no signal for digest).
|
|
257
|
+
*
|
|
258
|
+
* NOTE: This intentionally does NOT touch tool_use / tool_result / thinking
|
|
259
|
+
* content blocks — those are filtered upstream in extractText() (only
|
|
260
|
+
* type==="text" parts get through). This is purely about cleaning the user's
|
|
261
|
+
* own text + Claude's text replies of CLI command-shell pollution.
|
|
262
|
+
*/
|
|
263
|
+
export function sanitizeMessageText(text) {
|
|
264
|
+
if (!text)
|
|
265
|
+
return "";
|
|
266
|
+
let s = text;
|
|
267
|
+
// 1. Strip paired tag blocks (greedy match handles nested newlines + ANSI).
|
|
268
|
+
s = s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "");
|
|
269
|
+
s = s.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, "");
|
|
270
|
+
s = s.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, "");
|
|
271
|
+
s = s.replace(/<command-message>[\s\S]*?<\/command-message>/g, "");
|
|
272
|
+
s = s.replace(/<command-name>[\s\S]*?<\/command-name>/g, "");
|
|
273
|
+
s = s.replace(/<command-args>[\s\S]*?<\/command-args>/g, "");
|
|
274
|
+
// System-injected pseudo-messages: "Background command completed" / "task
|
|
275
|
+
// finished" notifications and the user-interrupt markers Claude Code stamps
|
|
276
|
+
// when the user hits Esc mid-tool-use. These appear under ## User but were
|
|
277
|
+
// not actually typed by the user.
|
|
278
|
+
s = s.replace(/<task-notification>[\s\S]*?<\/task-notification>/g, "");
|
|
279
|
+
s = s.replace(/\[Request interrupted by user[^\]]*\]/g, "");
|
|
280
|
+
// 2. Skill preamble. Skill instructions can be 100s of lines of template;
|
|
281
|
+
// they always start with "Base directory for this skill:" on its own line.
|
|
282
|
+
// Cut from that marker to either (a) the next "---" separator on its own
|
|
283
|
+
// line (skill files standard separator), (b) end of text. Be conservative:
|
|
284
|
+
// only strip when the marker is at the start of a line.
|
|
285
|
+
s = s.replace(/(^|\n)Base directory for this skill:[\s\S]*?(?=\n---\n|$)/g, "");
|
|
286
|
+
// 3. Whole-message API errors carry no work signal — drop the whole text.
|
|
287
|
+
if (/^\s*API Error:\s/.test(s))
|
|
288
|
+
return "";
|
|
289
|
+
// 4. Trim and length-gate.
|
|
290
|
+
s = s.trim();
|
|
291
|
+
if (s.length < 10)
|
|
292
|
+
return "";
|
|
293
|
+
return s;
|
|
294
|
+
}
|