evals/evals.json
{
"skill_name": "mode-creator",
"evals": [
{
"id": 1,
"prompt": "I run a small architecture studio and want claude-mem to remember client direction, code constraints, consultant conflicts, site findings, and approvals. Walk me through making that a mode, and alert me on Telegram for anything affecting cost or schedule.",
"expected_output": "Starts with the purpose-first interactive question, proposes a compact architecture taxonomy, obtains approval, installs a validated mode, securely configures selected Telegram concept triggers, restarts, and verifies the active mode in startup context.",
"files": []
},
{
"id": 2,
"prompt": "Can you make a custom claude-mem mode for my ML platform coding work? I especially lose experiment outcomes, dataset contract changes, GPU cost discoveries, and production rollback reasoning.",
"expected_output": "Explains that code mode already works, suggests concrete ML-platform types and tags, asks whether standard code or a custom variant is preferred, and avoids creating a redundant mode without approval.",
"files": []
},
{
"id": 3,
"prompt": "I'm studying constitutional law. I need memories for holdings, issue-spotting patterns, professor frameworks, minority rules, and exam traps. No messaging notifications.",
"expected_output": "Finds the bundled law-study mode, offers reuse or a focused remix, leaves Telegram settings untouched, activates the approved choice, restarts, and verifies the startup-context mode line.",
"files": []
}
]
}
references/mode-authoring.md
# Authoring a custom claude-mem mode
Read this reference when turning the approved interview into a mode file.
## Taxonomy rules
- Observation types answer “what kind of note is this?” They should be mutually exclusive enough that the observer can choose one.
- Concepts answer “why will I search for this later?” They are reusable, multi-value tags.
- Use lowercase kebab-case IDs. Labels can be natural language.
- Keep taxonomies small. Four to eight types and four to eight concepts is usually enough.
- Avoid overlapping types such as `decision`, `important-decision`, and `design-decision`. Put cross-cutting importance in a concept tag instead.
- Make every description operational: state what evidence causes that category to be selected.
- Include at least one skip rule. A mode that records everything quickly becomes noise.
## Inherited override shape
Use `code--<custom-id>` unless an existing bundled parent is a better fit. Arrays replace the parent's arrays; prompt objects merge by key. The override therefore needs the custom taxonomy plus every prompt whose inherited code meaning would be wrong.
Produce strict JSON without comments or placeholder angle brackets:
```json
{
"name": "Architecture Practice",
"description": "Design reasoning, constraints, approvals, and site discoveries for architecture projects",
"version": "1.0.0",
"observation_types": [
{
"id": "design-decision",
"label": "Design Decision",
"description": "A material spatial, structural, systems, or aesthetic choice with its rationale",
"emoji": "📐",
"work_emoji": "✏️"
}
],
"observation_concepts": [
{
"id": "client-priority",
"label": "Client Priority",
"description": "A stated or inferred client goal that affects later choices"
}
],
"prompts": {
"system_identity": "You are Claude-Mem, a specialized observer creating searchable memory for future sessions. Record what was learned, decided, approved, or changed about the architecture work—not the observer's own actions. All evidence arrives inside observed session messages; do not investigate independently.",
"spatial_awareness": "Use tool working directories and file paths to distinguish projects, drawing sets, specifications, correspondence, and site records.",
"observer_role": "Observe an architecture workflow happening now and preserve durable project knowledge for future sessions. Do not perform the work; record the substance and rationale of the work being observed.\n\nSILENT BY DESIGN: This observer session runs invisibly in the background. The session you are watching does not know it is being observed, and it must stay that way — an agent that knows it is being watched changes its behavior in unpredictable ways.\n\nNO CONTACT: Never contact, message, ping, or otherwise reach out to any other agent or session, including the one you are observing. Do not spawn subagents and do not attempt to influence the work in progress.",
"recording_focus": "WHAT TO RECORD\n--------------\nRecord durable design decisions, constraints, approvals, client priorities, coordination conflicts, and site discoveries. Prefer specific facts, affected spaces or systems, responsible parties, dates, and rationale.\n\nGOOD: The west facade glazing ratio was reduced to meet energy targets while preserving lobby daylight.\nBAD: Reviewed the facade and took notes.",
"skip_guidance": "WHEN TO SKIP\n------------\nSkip routine file navigation, formatting-only changes, repeated facts, unconfirmed speculation, and administrative activity with no project consequence. Return no observation when nothing durable was learned or changed.",
"type_guidance": "type must be exactly one of: design-decision, constraint, client-direction, coordination-issue, site-discovery, approval.",
"concept_guidance": "concepts must use only: client-priority, code-compliance, constructability, sustainability, cost-impact, schedule-impact. Concepts are tags and must not repeat the observation type.",
"field_guidance": "Facts must be concise and self-contained. Include project areas, systems, dimensions, standards, dates, parties, and status when known. List every source file or document examined.",
"format_examples": "",
"xml_title_placeholder": "[Short title naming the decision, constraint, direction, issue, discovery, or approval]",
"xml_subtitle_placeholder": "[One sentence with the project consequence, maximum 24 words]",
"xml_fact_placeholder": "[One self-contained project fact]",
"xml_narrative_placeholder": "[Context, rationale, affected work, and why this matters later]",
"xml_concept_placeholder": "[one approved concept ID]",
"xml_file_placeholder": "[path/to/drawing/specification/correspondence]",
"xml_summary_request_placeholder": "[Architecture task and substantive work discussed or completed]",
"xml_summary_investigated_placeholder": "[Drawings, specifications, requirements, precedents, or site conditions examined]",
"xml_summary_learned_placeholder": "[Design knowledge, constraints, priorities, and coordination findings learned]",
"xml_summary_completed_placeholder": "[Decisions, revisions, approvals, or analyses completed]",
"xml_summary_next_steps_placeholder": "[Current design, coordination, documentation, or approval trajectory]",
"xml_summary_notes_placeholder": "[Risks, unresolved questions, dependencies, or reminders]",
"header_memory_start": "ARCHITECTURE MEMORY START\n=========================",
"header_memory_continued": "ARCHITECTURE MEMORY CONTINUED\n=============================",
"header_summary_checkpoint": "ARCHITECTURE SUMMARY CHECKPOINT\n===============================",
"continuation_greeting": "You are continuing to observe the architecture work in the primary session.",
"summary_instruction": "Summarize what architecture work was examined, learned, decided, or completed and the current next steps. Preserve rationale, constraints, approvals, unresolved issues, and affected project areas.",
"summary_context_label": "Primary session response:",
"summary_format_instruction": "Respond using the required XML summary format:",
"summary_footer": "Generate only the progress summary for the observed architecture session. Do not perform new work or describe the observer's actions."
}
}
```
The parent supplies stable output-protocol fields that remain domain-neutral, including `output_format_header`, `footer`, and `continuation_instruction`. The installer merges the parent and validates every required field before writing anything.
## Prompt quality checklist
- `system_identity` says the model observes another session and records substance, not its own actions.
- `observer_role` states that the observer runs silently in the background and never contacts another agent or session, including the one it observes.
- `recording_focus` gives domain-specific record rules and at least one good/bad example.
- `skip_guidance` prevents routine or speculative noise.
- `type_guidance` mentions every type ID exactly as authored.
- `concept_guidance` mentions every concept ID and distinguishes tags from types.
- Summary prompts use the user's domain vocabulary rather than software vocabulary.
- Examples contain no real secrets, private client names, or personal data.
- The mode prompt does not mention Telegram. Notifications happen after an observation is parsed.
## Existing modes worth checking first
- `code`: general software development.
- `code--chill`: selective software memory.
- `law-study`: case holdings, issue patterns, doctrine, professor frameworks, and exam concepts.
- `email-investigation`: entities, relationships, evidence, anomalies, and conclusions.
Remix a close mode instead of duplicating it. Never overwrite a bundled ID.
references/telegram.md
# Telegram notifications for a custom mode
Read this only after the user opts into alerts.
## How matching works
claude-mem reads these settings from its data-directory `settings.json`:
- `CLAUDE_MEM_TELEGRAM_ENABLED`
- `CLAUDE_MEM_TELEGRAM_BOT_TOKEN`
- `CLAUDE_MEM_TELEGRAM_CHAT_ID`
- `CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES`
- `CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS`
An observation sends when its single type matches any configured trigger type **or** one of its concepts matches any configured trigger concept. No trigger list means no messages.
Messages contain the observation type, title, subtitle, project, and observation ID. They do not include the full narrative or facts, but titles and subtitles can still contain sensitive information. Make the privacy tradeoff explicit before configuration.
## Bot setup
1. Open Telegram's official [@BotFather](https://t.me/BotFather).
2. Send `/newbot`, choose a display name, then choose a unique username ending in `bot`.
3. BotFather returns an authentication token. Treat it like a password; anyone with it controls the bot.
4. Open the new bot, press **Start**, and send it a message. Bots cannot initiate a private conversation before the user contacts them.
5. Run `scripts/configure-telegram.mjs` from this skill. It collects the token through hidden terminal input, validates it with `getMe`, uses `getUpdates` to discover a recent chat when possible, sends a test with `sendMessage`, and stores the result with owner-only permissions.
Official references: [Telegram bots introduction](https://core.telegram.org/bots), [BotFather features](https://core.telegram.org/bots/features), and [Bot API methods](https://core.telegram.org/bots/api).
## Security rules
- Never request the token through an ordinary chat response or interactive question whose answer is reproduced in the transcript.
- Never put the token in a URL printed to the terminal, a shell command, an environment assignment shown in chat, or a command-line argument.
- Never print `settings.json` wholesale after configuration.
- It is safe to report whether a token is present, the bot username returned by `getMe`, and the selected chat ID.
- Keep settings and backups mode `0600`.
- If a token was exposed, tell the user to revoke it through BotFather and create a replacement before continuing.
## Troubleshooting
- `getMe failed: Unauthorized`: the token is wrong or revoked. Generate a new token in BotFather.
- No chats found: the user must press Start and send the bot a message, then retry.
- `getUpdates` says a webhook is active: automatic discovery cannot run while a webhook owns updates. Enter the numeric chat ID manually; do not delete a webhook without explicit permission.
- `sendMessage` says chat not found: verify the chat ID and ensure the bot was started or added to the group.
- Group alerts: add the bot to the group, send a message that the bot can receive, and use the negative group chat ID.
- Test succeeds but observations do not alert: confirm the generated observation's type/concepts exactly match the configured lowercase IDs and restart the worker after settings changes.
scripts/configure-telegram.mjs
#!/usr/bin/env node
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
function fail(message) {
console.error(`mode-creator Telegram setup: ${message}`);
process.exit(1);
}
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 2) {
const token = argv[index];
const value = argv[index + 1];
if (!token?.startsWith('--') || !value) fail('usage: configure-telegram.mjs [--types <csv>] [--concepts <csv>]');
result[token.slice(2)] = value;
}
return result;
}
function expandHome(value) {
if (value === '~') return homedir();
if (value?.startsWith('~/') || value?.startsWith('~\\')) return path.join(homedir(), value.slice(2));
return value;
}
function readJson(filePath) {
try {
return JSON.parse(readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
} catch (error) {
fail(`could not parse ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function resolveDataDir() {
if (process.env.CLAUDE_MEM_DATA_DIR) return expandHome(process.env.CLAUDE_MEM_DATA_DIR);
const defaultDir = path.join(homedir(), '.claude-mem');
const defaultSettings = path.join(defaultDir, 'settings.json');
if (!existsSync(defaultSettings)) return defaultDir;
const parsed = readJson(defaultSettings);
const flat = parsed.env && typeof parsed.env === 'object' ? parsed.env : parsed;
return flat.CLAUDE_MEM_DATA_DIR ? expandHome(flat.CLAUDE_MEM_DATA_DIR) : defaultDir;
}
function splitCsv(value) {
return String(value ?? '').split(',').map(item => item.trim()).filter(Boolean);
}
function mergeCsv(existing, additions) {
return [...new Set([...splitCsv(existing), ...additions])].join(',');
}
async function promptLine(label, { hidden = false, allowEmpty = false } = {}) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
fail('interactive terminal required; run this helper directly in your terminal');
}
process.stdout.write(label);
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
let value = '';
return await new Promise((resolve, reject) => {
const finish = () => {
process.stdin.off('data', onData);
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdout.write('\n');
};
const onData = chunk => {
for (const character of chunk) {
if (character === '\u0003') {
finish();
reject(new Error('cancelled'));
return;
}
if (character === '\r' || character === '\n') {
if (!allowEmpty && value.trim().length === 0) continue;
finish();
resolve(value.trim());
return;
}
if (character === '\u007f' || character === '\b') {
if (value.length > 0) {
value = value.slice(0, -1);
process.stdout.write('\b \b');
}
continue;
}
value += character;
process.stdout.write(hidden ? '•' : character);
}
};
process.stdin.on('data', onData);
});
}
async function askYesNo(label, defaultYes = true) {
const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
const answer = (await promptLine(`${label}${suffix}`, { allowEmpty: true })).toLowerCase();
if (!answer) return defaultYes;
return answer === 'y' || answer === 'yes';
}
async function telegramCall(token, method, body) {
const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
method: body ? 'POST' : 'GET',
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
const description = payload?.description ?? `${response.status} ${response.statusText}`;
throw new Error(`${method} failed: ${description}`);
}
return payload.result;
}
function chatsFromUpdates(updates) {
const chats = new Map();
for (const update of updates) {
const chat = update.message?.chat
?? update.edited_message?.chat
?? update.channel_post?.chat
?? update.callback_query?.message?.chat;
if (!chat) continue;
const label = chat.title ?? chat.username ?? [chat.first_name, chat.last_name].filter(Boolean).join(' ') ?? String(chat.id);
chats.set(String(chat.id), label || String(chat.id));
}
return [...chats.entries()];
}
function atomicWriteJson(filePath, value) {
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`);
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
chmodSync(tempPath, 0o600);
renameSync(tempPath, filePath);
}
const args = parseArgs(process.argv.slice(2));
const types = splitCsv(args.types);
const concepts = splitCsv(args.concepts);
const dataDir = resolveDataDir();
const settingsPath = path.join(dataDir, 'settings.json');
let settings = existsSync(settingsPath) ? readJson(settingsPath) : {};
if (settings.env && typeof settings.env === 'object') settings = settings.env;
try {
let token = process.env.CLAUDE_MEM_TELEGRAM_BOT_TOKEN ?? '';
if (!token && settings.CLAUDE_MEM_TELEGRAM_BOT_TOKEN) {
if (await askYesNo('Use the Telegram bot token already saved in claude-mem?')) {
token = settings.CLAUDE_MEM_TELEGRAM_BOT_TOKEN;
}
}
if (!token) token = await promptLine('Paste the BotFather token (input is hidden): ', { hidden: true });
const bot = await telegramCall(token, 'getMe');
console.log(`Authenticated as @${bot.username ?? bot.first_name}.`);
let chatId = process.env.CLAUDE_MEM_TELEGRAM_CHAT_ID ?? '';
if (!chatId && settings.CLAUDE_MEM_TELEGRAM_CHAT_ID) {
if (await askYesNo(`Use the saved chat ID ${settings.CLAUDE_MEM_TELEGRAM_CHAT_ID}?`)) {
chatId = settings.CLAUDE_MEM_TELEGRAM_CHAT_ID;
}
}
if (!chatId) {
console.log(`Open https://t.me/${bot.username}, press Start, and send any message.`);
await promptLine('Press Enter after the message has been sent: ', { allowEmpty: true });
try {
const chats = chatsFromUpdates(await telegramCall(token, 'getUpdates'));
if (chats.length === 1 && await askYesNo(`Use ${chats[0][1]} (${chats[0][0]})?`)) {
chatId = chats[0][0];
} else if (chats.length > 1) {
console.log('Recent chats:');
for (const [id, label] of chats) console.log(` ${id} ${label}`);
}
} catch (error) {
console.warn(`Could not discover the chat automatically (${error instanceof Error ? error.message : String(error)}).`);
console.warn('This commonly happens when the bot already has a webhook; enter the chat ID manually.');
}
}
if (!chatId) chatId = await promptLine('Telegram chat ID (numeric, groups are usually negative): ');
if (!/^-?\d+$/.test(chatId) && !/^@[A-Za-z0-9_]{5,}$/.test(chatId)) fail('chat ID must be numeric or an @channel username');
await telegramCall(token, 'sendMessage', {
chat_id: chatId,
text: '✅ claude-mem Telegram notifications are connected.',
});
const backupPath = existsSync(settingsPath)
? `${settingsPath}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`
: null;
if (backupPath) {
copyFileSync(settingsPath, backupPath);
chmodSync(backupPath, 0o600);
}
settings.CLAUDE_MEM_TELEGRAM_ENABLED = 'true';
settings.CLAUDE_MEM_TELEGRAM_BOT_TOKEN = token;
settings.CLAUDE_MEM_TELEGRAM_CHAT_ID = chatId;
if (types.length > 0) settings.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES = mergeCsv(settings.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES, types);
if (concepts.length > 0) settings.CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS = mergeCsv(settings.CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS, concepts);
atomicWriteJson(settingsPath, settings);
console.log(JSON.stringify({
ok: true,
bot: bot.username ?? bot.first_name,
chatId,
triggerTypes: splitCsv(settings.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES),
triggerConcepts: splitCsv(settings.CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS),
settingsPath,
backupPath,
}, null, 2));
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
scripts/install-mode.mjs
#!/usr/bin/env node
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const MODE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*)?$/;
const ITEM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const REQUIRED_PROMPTS = [
'system_identity',
'spatial_awareness',
'observer_role',
'recording_focus',
'skip_guidance',
'type_guidance',
'concept_guidance',
'field_guidance',
'output_format_header',
'format_examples',
'footer',
'xml_title_placeholder',
'xml_subtitle_placeholder',
'xml_fact_placeholder',
'xml_narrative_placeholder',
'xml_concept_placeholder',
'xml_file_placeholder',
'xml_summary_request_placeholder',
'xml_summary_investigated_placeholder',
'xml_summary_learned_placeholder',
'xml_summary_completed_placeholder',
'xml_summary_next_steps_placeholder',
'xml_summary_notes_placeholder',
'header_memory_start',
'header_memory_continued',
'header_summary_checkpoint',
'continuation_greeting',
'continuation_instruction',
'summary_instruction',
'summary_context_label',
'summary_format_instruction',
'summary_footer',
];
function fail(message) {
console.error(`mode-creator: ${message}`);
process.exit(1);
}
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith('--')) fail(`unexpected argument: ${token}`);
const key = token.slice(2);
if (key === 'dry-run' || key === 'no-activate') {
result[key] = true;
continue;
}
const value = argv[index + 1];
if (!value || value.startsWith('--')) fail(`missing value for --${key}`);
result[key] = value;
index += 1;
}
return result;
}
function expandHome(value) {
if (value === '~') return homedir();
if (value?.startsWith('~/') || value?.startsWith('~\\')) {
return path.join(homedir(), value.slice(2));
}
return value;
}
function readJson(filePath, label = filePath) {
try {
return JSON.parse(readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
} catch (error) {
fail(`could not parse ${label}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function resolveDataDir() {
if (process.env.CLAUDE_MEM_DATA_DIR) return expandHome(process.env.CLAUDE_MEM_DATA_DIR);
const defaultDir = path.join(homedir(), '.claude-mem');
const defaultSettings = path.join(defaultDir, 'settings.json');
if (!existsSync(defaultSettings)) return defaultDir;
const parsed = readJson(defaultSettings, 'claude-mem settings');
const flat = parsed.env && typeof parsed.env === 'object' ? parsed.env : parsed;
return flat.CLAUDE_MEM_DATA_DIR ? expandHome(flat.CLAUDE_MEM_DATA_DIR) : defaultDir;
}
function deepMerge(base, override) {
if (!base || typeof base !== 'object' || Array.isArray(base)) return override;
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
const baseValue = result[key];
if (
value && typeof value === 'object' && !Array.isArray(value)
&& baseValue && typeof baseValue === 'object' && !Array.isArray(baseValue)
) {
result[key] = deepMerge(baseValue, value);
} else {
result[key] = value;
}
}
return result;
}
function splitCsv(value) {
return String(value ?? '')
.split(',')
.map(item => item.trim())
.filter(Boolean);
}
function mergeCsv(existing, additions) {
return [...new Set([...splitCsv(existing), ...additions])].join(',');
}
function requireString(value, label, allowEmpty = false) {
if (typeof value !== 'string' || (!allowEmpty && value.trim().length === 0)) {
fail(`${label} must be ${allowEmpty ? 'a string' : 'a non-empty string'}`);
}
}
function validateItems(items, label, requiredFields) {
if (!Array.isArray(items) || items.length === 0) fail(`${label} must contain at least one item`);
const seen = new Set();
for (const [index, item] of items.entries()) {
if (!item || typeof item !== 'object' || Array.isArray(item)) fail(`${label}[${index}] must be an object`);
for (const field of requiredFields) requireString(item[field], `${label}[${index}].${field}`);
if (!ITEM_ID_PATTERN.test(item.id)) fail(`${label}[${index}].id must be lowercase kebab-case`);
if (seen.has(item.id)) fail(`${label} contains duplicate id: ${item.id}`);
seen.add(item.id);
}
}
function validateMergedMode(mode) {
if (!mode || typeof mode !== 'object' || Array.isArray(mode)) fail('mode must be a JSON object');
requireString(mode.name, 'name');
requireString(mode.description, 'description');
requireString(mode.version, 'version');
validateItems(mode.observation_types, 'observation_types', ['id', 'label', 'description', 'emoji', 'work_emoji']);
validateItems(mode.observation_concepts, 'observation_concepts', ['id', 'label', 'description']);
if (!mode.prompts || typeof mode.prompts !== 'object' || Array.isArray(mode.prompts)) {
fail('prompts must be an object');
}
for (const prompt of REQUIRED_PROMPTS) requireString(mode.prompts[prompt], `prompts.${prompt}`, prompt === 'format_examples');
for (const type of mode.observation_types) {
if (!mode.prompts.type_guidance.includes(type.id)) fail(`prompts.type_guidance does not mention type: ${type.id}`);
}
for (const concept of mode.observation_concepts) {
if (!mode.prompts.concept_guidance.includes(concept.id)) fail(`prompts.concept_guidance does not mention concept: ${concept.id}`);
}
}
function atomicWriteJson(filePath, value, mode = 0o600) {
mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`);
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode });
chmodSync(tempPath, mode);
renameSync(tempPath, filePath);
}
function timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-');
}
const args = parseArgs(process.argv.slice(2));
if (!args.mode) fail('usage: install-mode.mjs --mode <draft.json> [--mode-id <id>] [--telegram-types <csv>] [--telegram-concepts <csv>] [--dry-run]');
const sourcePath = path.resolve(args.mode);
if (!existsSync(sourcePath)) fail(`draft mode file not found: ${sourcePath}`);
const modeId = args['mode-id'] ?? path.basename(sourcePath, path.extname(sourcePath));
if (!MODE_ID_PATTERN.test(modeId)) fail('mode ID must be lowercase kebab-case, optionally parent--override');
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const bundledModesDir = path.resolve(scriptDir, '../../../modes');
const dataDir = resolveDataDir();
const userModesDir = path.join(dataDir, 'modes');
const settingsPath = path.join(dataDir, 'settings.json');
const destinationPath = path.join(userModesDir, `${modeId}.json`);
const draft = readJson(sourcePath, 'draft mode');
if (existsSync(path.join(bundledModesDir, `${modeId}.json`))) {
fail(`mode ID collides with bundled mode: ${modeId}; choose a unique custom ID instead of shadowing bundled configuration`);
}
let merged = draft;
const inheritanceParts = modeId.split('--');
if (inheritanceParts.length === 2) {
const parentId = inheritanceParts[0];
const parentCandidates = [
path.join(userModesDir, `${parentId}.json`),
path.join(bundledModesDir, `${parentId}.json`),
];
const parentPath = parentCandidates.find(candidate => existsSync(candidate));
if (!parentPath) fail(`parent mode not found: ${parentId}`);
merged = deepMerge(readJson(parentPath, `parent mode ${parentId}`), draft);
} else if (inheritanceParts.length !== 1) {
fail('only one inheritance level is supported');
}
validateMergedMode(merged);
const typeIds = new Set(merged.observation_types.map(item => item.id));
const conceptIds = new Set(merged.observation_concepts.map(item => item.id));
const telegramTypes = splitCsv(args['telegram-types']);
const telegramConcepts = splitCsv(args['telegram-concepts']);
for (const type of telegramTypes) if (!typeIds.has(type)) fail(`Telegram trigger type is not in this mode: ${type}`);
for (const concept of telegramConcepts) if (!conceptIds.has(concept)) fail(`Telegram trigger concept is not in this mode: ${concept}`);
if (args['dry-run']) {
console.log(JSON.stringify({ ok: true, dryRun: true, modeId, sourcePath, destinationPath, telegramTypes, telegramConcepts }, null, 2));
process.exit(0);
}
mkdirSync(userModesDir, { recursive: true, mode: 0o700 });
const backupStamp = timestamp();
let modeBackup = null;
if (existsSync(destinationPath)) {
modeBackup = `${destinationPath}.backup-${backupStamp}`;
copyFileSync(destinationPath, modeBackup);
chmodSync(modeBackup, 0o600);
}
atomicWriteJson(destinationPath, draft);
let settings = {};
let settingsBackup = null;
if (existsSync(settingsPath)) {
const parsed = readJson(settingsPath, 'claude-mem settings');
settings = parsed.env && typeof parsed.env === 'object' ? parsed.env : parsed;
settingsBackup = `${settingsPath}.backup-${backupStamp}`;
copyFileSync(settingsPath, settingsBackup);
chmodSync(settingsBackup, 0o600);
}
if (!args['no-activate']) settings.CLAUDE_MEM_MODE = modeId;
if (telegramTypes.length > 0 || telegramConcepts.length > 0) {
settings.CLAUDE_MEM_TELEGRAM_ENABLED = 'true';
settings.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES = mergeCsv(settings.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES, telegramTypes);
settings.CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS = mergeCsv(settings.CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS, telegramConcepts);
}
atomicWriteJson(settingsPath, settings);
console.log(JSON.stringify({
ok: true,
modeId,
modeName: merged.name,
installedAt: destinationPath,
activated: !args['no-activate'],
telegramTypes,
telegramConcepts,
backups: { mode: modeBackup, settings: settingsBackup },
}, null, 2));
SKILL.md
---
name: mode-creator
description: Interactively create, install, activate, and verify custom claude-mem modes, including domain-specific observation types, concept tags, optional Telegram alerts, bot setup, worker restart, and startup-context verification. Use this whenever someone asks to customize what claude-mem remembers, create or change a mode, track domain-specific notes, add observation types or tags, or send Telegram notifications for particular memories—even if they do not use the word "mode."
compatibility: Requires a local claude-mem worker installation, an interactive question tool, filesystem access, and Node.js 20+. Telegram setup requires network access and a Telegram account.
---
# Mode Creator
Create a useful note-taking system, not merely a valid JSON file. Interview the user, propose a small taxonomy, obtain approval, install it durably, configure optional alerts, restart the worker, and prove the active mode appears in startup context.
## Ground rules
- Use the available interactive question tool (`AskUserQuestion`, `request_user_input`, or equivalent) for the interview. Ask in small batches and wait for each response.
- Explain observation types as mutually exclusive kinds of notes and concepts as reusable tags. Avoid jargon unless the user uses it first.
- Inspect existing bundled and user modes before inventing a new one. Reuse or remix a close match when that serves the user better.
- Do not edit a plugin cache or bundled mode. Install custom files under the resolved claude-mem data directory's `modes/` folder.
- Do not expose a Telegram token in chat, command arguments, logs, or tool output. Treat it like a password.
- Preserve unrelated settings and existing Telegram triggers. The helpers make timestamped backups and merge requested triggers.
- Custom modes are supported by the local worker runtime. If `CLAUDE_MEM_RUNTIME` is `server`, explain that this workflow cannot safely install a per-user mode into the shared server and stop before mutation.
- Existing observations keep their original types. The new mode applies to future observation generation.
## 1. Open with the purpose
Begin with this message inside the first interactive question:
> Custom modes let you take notes for whatever you're working on. If you're a law student, you may want to write down every time a case establishes a rule, a professor flags an exam trap, or doctrines conflict. If you're an architect, you may want to capture every design decision, code constraint, client preference, or site discovery. What are you working on?
Do not start by asking for a mode name or JSON fields. Learn the work first.
If the answer is code-related, say:
> Code mode already works well for software work. A custom variant may work better if it also tracks [2–4 specific kinds of notes inferred from their work] and tags [2–4 useful cross-cutting themes]. Would you like to keep standard code mode or customize it?
Use concrete suggestions. For an ML platform engineer, for example, suggest experiment outcomes, data-contract changes, production incidents, model decisions, cost findings, and reproducibility risks—not generic “custom notes.” If the user chooses standard code mode, do not create a redundant file; continue to the optional notification and verification steps.
## 2. Discover what is worth remembering
Use follow-up questions to obtain:
1. Three examples of moments or findings they would want available next week.
2. Routine activity that should be skipped.
3. The nouns and decisions they search for later: people, cases, materials, clients, constraints, experiments, incidents, and so on.
4. Anything sensitive that should never be recorded or sent to Telegram.
5. Whether notes should be selective or detailed.
Infer answers already present in the conversation instead of asking twice. When the user gives a broad answer, propose examples and let them select or edit them.
## 3. Propose the mode
Read [references/mode-authoring.md](references/mode-authoring.md) before drafting.
Propose:
- A clear mode name and lowercase ID.
- Usually 4–8 observation types. Each observed item gets exactly one type.
- Usually 4–8 concept tags. An item may get several concepts.
- One-sentence recording and skipping policies.
- Two realistic notes the mode would record and two it would skip.
Present the proposal in plain language and use the interactive question tool for approval. Let the user rename, add, remove, or reword categories. Do not write or install until they approve the taxonomy and privacy boundary.
Prefer an inherited ID such as `code--architecture-practice` so the mode reuses claude-mem's stable output protocol while replacing the domain taxonomy and behavioral prompts. The `code` parent is an implementation base; the override must remove code-specific semantics from the prompts. Use a standalone mode only when inheritance is genuinely unsuitable.
## 4. Ask about Telegram alerts
After the taxonomy is approved, ask:
> Would you like Telegram notifications when claude-mem records any particular types or tags? Alerts include the observation type, title, subtitle, project, and observation ID, so avoid selecting categories that may expose sensitive material.
If yes:
- Let the user select exact observation types and/or concept tags from the approved mode.
- Explain that matching is OR: any selected type or any selected concept sends an alert.
- Ask whether they already have a Telegram bot connected to claude-mem.
- Read [references/telegram.md](references/telegram.md), then guide new users through BotFather and the secure setup helper.
If no, leave every Telegram setting unchanged.
## 5. Draft, validate, and install
Resolve the absolute directory containing this `SKILL.md`; all helper paths are relative to that directory.
Write the approved mode to a temporary JSON file. Use the exact inherited override shape in the authoring reference. Then validate without mutating anything:
```bash
node <skill-directory>/scripts/install-mode.mjs \
--mode <temporary-mode.json> \
--mode-id <parent--custom-id> \
--dry-run
```
Fix every validation error before installation. Then install and activate it:
```bash
node <skill-directory>/scripts/install-mode.mjs \
--mode <temporary-mode.json> \
--mode-id <parent--custom-id> \
--telegram-types <comma-separated-approved-types> \
--telegram-concepts <comma-separated-approved-concepts>
```
Omit both Telegram flags when alerts were declined. The installer:
- Merges the override with its parent and validates the complete mode.
- Installs the source override under `<data-dir>/modes/`.
- Sets `CLAUDE_MEM_MODE` in `settings.json`.
- Merges approved alert triggers without deleting existing triggers.
- Writes atomically and reports any backup paths.
Review its JSON result. Do not claim success if `ok` is not `true`.
## 6. Connect Telegram when needed
If alerts were requested and both bot token and chat ID are already present, ask permission to reuse them and send a test. If credentials are missing, explain the BotFather steps from the Telegram reference.
Run the credential helper only after explicit consent:
```bash
node <skill-directory>/scripts/configure-telegram.mjs \
--types <comma-separated-approved-types> \
--concepts <comma-separated-approved-concepts>
```
The helper accepts the token through hidden terminal input, validates it with `getMe`, discovers or asks for the chat ID, sends a test message, and stores the settings with owner-only permissions. Never pass the token as an argument.
If the agent environment cannot give the user control of an interactive terminal, show the exact helper command and pause for the user to run it locally. This is the only acceptable manual boundary; do not ask them to paste the token into chat as a workaround. After they confirm, inspect only whether the credential fields are present—never print their values.
## 7. Restart and prove the result
Read the configured runtime before restarting. For a worker runtime, use the verified CLI restart path:
```bash
npx claude-mem restart
npx claude-mem status
```
If the CLI shim is unavailable, run the installed plugin's `scripts/worker-service.cjs restart` with Bun. Do not use a bare restart HTTP request when the verified CLI path is available.
Verify all of the following:
1. Restart reports a new healthy worker and exits successfully.
2. The installed file exists under the resolved data directory.
3. `settings.json` names the intended `CLAUDE_MEM_MODE` without displaying secrets.
4. Request full startup context with the `session_start_context` MCP tool when available. Otherwise call `/api/context/inject?project=mode-creator-verification&full=true` on the configured local worker.
5. Startup context contains `Mode: <mode name> (<mode id>)`.
6. If Telegram was configured, the test message arrived.
If the worker falls back to `code`, inspect the worker log for a mode validation or lookup error, repair the mode, and repeat the restart. Do not describe a fallback as successful activation.
## 8. Hand off clearly
Conclude with:
- Active mode name and ID.
- Installed path.
- Observation types and concepts.
- Telegram trigger types/concepts, or “unchanged.”
- Restart and startup-context verification result.
- Backup paths for rollback.
- One short example of what the new mode will now remember.
Never include the Telegram bot token in the handoff.