feat: add quick-win features (search, templates, backup, notifications)

Tier 4 quick-win features from the product roadmap:

- Conversation search & bookmarks: full-text search across JSONL transcripts
  with filters (agent, channel, since) and bookmark persistence
- Response templates: CRUD with variable expansion, per-channel overrides,
  per-agent filtering
- Backup & export: create/restore with SHA256 integrity, export to
  Markdown/JSON/JSONL with filtering
- Push notifications: priority-based routing, quiet hours, smart grouping,
  listener API for push providers

New CLI commands:
  moltbot search <query> [--agent|--channel|--since|--limit|--json|--bookmarks]
  moltbot bookmark [--add|--remove|--list]
  moltbot templates list|add|remove|show|update
  moltbot backup create|restore|export
  moltbot notifications list|read|clear|prefs (alias: notif)

51 new tests across 4 test files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Apple 2026-01-29 09:57:08 +05:30
parent 9d75e6dc9c
commit cc6e18ff2b
15 changed files with 2975 additions and 1 deletions

View File

@ -3,6 +3,7 @@ import type { Command } from "commander";
import { agentsListCommand } from "../../commands/agents.js"; import { agentsListCommand } from "../../commands/agents.js";
import { healthCommand } from "../../commands/health.js"; import { healthCommand } from "../../commands/health.js";
import { sessionsCommand } from "../../commands/sessions.js"; import { sessionsCommand } from "../../commands/sessions.js";
import { sessionsSearchCommand } from "../../commands/sessions-search.js";
import { statusCommand } from "../../commands/status.js"; import { statusCommand } from "../../commands/status.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js"; import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
@ -10,6 +11,8 @@ import { registerBrowserCli } from "../browser-cli.js";
import { registerConfigCli } from "../config-cli.js"; import { registerConfigCli } from "../config-cli.js";
import { registerMemoryCli, runMemoryStatus } from "../memory-cli.js"; import { registerMemoryCli, runMemoryStatus } from "../memory-cli.js";
import { registerAgentCommands } from "./register.agent.js"; import { registerAgentCommands } from "./register.agent.js";
import { registerBackupCommands } from "./register.backup.js";
import { registerNotificationsCommands } from "./register.notifications.js";
import { registerConfigureCommand } from "./register.configure.js"; import { registerConfigureCommand } from "./register.configure.js";
import { registerMaintenanceCommands } from "./register.maintenance.js"; import { registerMaintenanceCommands } from "./register.maintenance.js";
import { registerMessageCommands } from "./register.message.js"; import { registerMessageCommands } from "./register.message.js";
@ -17,6 +20,7 @@ import { registerOnboardCommand } from "./register.onboard.js";
import { registerSetupCommand } from "./register.setup.js"; import { registerSetupCommand } from "./register.setup.js";
import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js"; import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js";
import { registerSubCliCommands } from "./register.subclis.js"; import { registerSubCliCommands } from "./register.subclis.js";
import { registerTemplatesCommands } from "./register.templates.js";
import type { ProgramContext } from "./context.js"; import type { ProgramContext } from "./context.js";
type CommandRegisterParams = { type CommandRegisterParams = {
@ -89,6 +93,39 @@ const routeAgentsList: RouteSpec = {
}, },
}; };
const routeSearch: RouteSpec = {
match: (path) => path[0] === "search",
run: async (argv) => {
const json = hasFlag(argv, "--json");
const agent = getFlagValue(argv, "--agent");
if (agent === null) return false;
const channel = getFlagValue(argv, "--channel");
if (channel === null) return false;
const since = getFlagValue(argv, "--since");
if (since === null) return false;
const limitRaw = getFlagValue(argv, "--limit");
if (limitRaw === null) return false;
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
const bookmarks = hasFlag(argv, "--bookmarks");
// Extract positional query argument (first non-flag arg after "search")
const searchIdx = argv.indexOf("search");
let query = "";
if (searchIdx >= 0) {
for (let i = searchIdx + 1; i < argv.length; i++) {
if (!argv[i].startsWith("-")) {
query = argv[i];
break;
}
}
}
await sessionsSearchCommand(
{ query, agent, channel, since, limit, json, bookmarks },
defaultRuntime,
);
return true;
},
};
const routeMemoryStatus: RouteSpec = { const routeMemoryStatus: RouteSpec = {
match: (path) => path[0] === "memory" && path[1] === "status", match: (path) => path[0] === "memory" && path[1] === "status",
run: async (argv) => { run: async (argv) => {
@ -146,12 +183,24 @@ export const commandRegistry: CommandRegistration[] = [
{ {
id: "status-health-sessions", id: "status-health-sessions",
register: ({ program }) => registerStatusHealthSessionsCommands(program), register: ({ program }) => registerStatusHealthSessionsCommands(program),
routes: [routeHealth, routeStatus, routeSessions], routes: [routeHealth, routeStatus, routeSessions, routeSearch],
}, },
{ {
id: "browser", id: "browser",
register: ({ program }) => registerBrowserCli(program), register: ({ program }) => registerBrowserCli(program),
}, },
{
id: "templates",
register: ({ program }) => registerTemplatesCommands(program),
},
{
id: "backup",
register: ({ program }) => registerBackupCommands(program),
},
{
id: "notifications",
register: ({ program }) => registerNotificationsCommands(program),
},
]; ];
export function registerProgramCommands( export function registerProgramCommands(

View File

@ -0,0 +1,104 @@
import type { Command } from "commander";
import {
backupCreateCommand,
backupExportCommand,
backupRestoreCommand,
} from "../../commands/backup.js";
import { defaultRuntime } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
export function registerBackupCommands(program: Command) {
const backup = program
.command("backup")
.description("Backup, restore, and export Moltbot data");
backup
.command("create")
.description("Create a full backup of config, sessions, and memory")
.option("--output <path>", "Output directory (default: ~/moltbot-backup-<timestamp>)")
.option("--include-credentials", "Include API keys and tokens in backup", false)
.option("--json", "Output result as JSON", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["moltbot backup create", "Create backup in home directory."],
["moltbot backup create --output /tmp/backup", "Specify output directory."],
["moltbot backup create --include-credentials", "Include API keys."],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupCreateCommand(
{
output: opts.output as string | undefined,
includeCredentials: Boolean(opts.includeCredentials),
json: Boolean(opts.json),
},
defaultRuntime,
);
});
});
backup
.command("restore")
.description("Restore from a backup directory")
.argument("<path>", "Path to backup directory")
.option("--dry-run", "Show what would be restored without making changes", false)
.option("--json", "Output result as JSON", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["moltbot backup restore ~/moltbot-backup-2024-01-15", "Restore from backup."],
["moltbot backup restore ~/backup --dry-run", "Preview restore without changes."],
])}`,
)
.action(async (inputPath, opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupRestoreCommand(
{
input: String(inputPath),
dryRun: Boolean(opts.dryRun),
json: Boolean(opts.json),
},
defaultRuntime,
);
});
});
backup
.command("export")
.description("Export conversations as Markdown, JSON, or JSONL")
.option("--format <format>", "Export format: markdown, json, jsonl", "markdown")
.option("--output <path>", "Output file path")
.option("--agent <id>", "Export only this agent's conversations")
.option("--session <key>", "Export only this session")
.option("--since <period>", "Only export messages after this time (e.g. 1h, 2d, 2024-01-01)")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["moltbot backup export", "Export all conversations as Markdown."],
["moltbot backup export --format json", "Export as JSON."],
["moltbot backup export --agent pi --since 7d", "Last 7 days from pi agent."],
["moltbot backup export --format jsonl --output ./export.jsonl", "JSONL to file."],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupExportCommand(
{
format: opts.format as "markdown" | "json" | "jsonl" | undefined,
output: opts.output as string | undefined,
agent: opts.agent as string | undefined,
session: opts.session as string | undefined,
since: opts.since as string | undefined,
},
defaultRuntime,
);
});
});
}

View File

@ -0,0 +1,114 @@
import type { Command } from "commander";
import {
notificationsClearCommand,
notificationsListCommand,
notificationsPrefsCommand,
notificationsReadCommand,
} from "../../commands/notifications.js";
import { defaultRuntime } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
export function registerNotificationsCommands(program: Command) {
const notif = program
.command("notifications")
.alias("notif")
.description("Manage push notifications and preferences");
notif
.command("list")
.description("List notifications")
.option("--json", "Output as JSON", false)
.option("--unread", "Show only unread notifications", false)
.option("--category <name>", "Filter by category (message, agent-complete, tool-approval, error, system)")
.option("--channel <name>", "Filter by channel")
.option("--agent <id>", "Filter by agent")
.option("--limit <n>", "Maximum results", "50")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["moltbot notifications list", "List all notifications."],
["moltbot notifications list --unread", "Show only unread."],
["moltbot notifications list --category error", "Show only errors."],
["moltbot notif list --json", "JSON output."],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await notificationsListCommand(
{
json: Boolean(opts.json),
unread: Boolean(opts.unread),
category: opts.category as string | undefined,
channel: opts.channel as string | undefined,
agent: opts.agent as string | undefined,
limit: opts.limit ? Number.parseInt(String(opts.limit), 10) : 50,
},
defaultRuntime,
);
});
});
notif
.command("read")
.description("Mark notifications as read")
.option("--id <id>", "Mark a specific notification as read")
.option("--all", "Mark all notifications as read", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await notificationsReadCommand(
{
id: opts.id as string | undefined,
all: Boolean(opts.all),
},
defaultRuntime,
);
});
});
notif
.command("clear")
.description("Clear all notifications")
.action(async () => {
await runCommandWithRuntime(defaultRuntime, async () => {
await notificationsClearCommand(defaultRuntime);
});
});
notif
.command("prefs")
.description("View or update notification preferences")
.option("--json", "Output as JSON", false)
.option("--enabled <bool>", "Enable/disable notifications (true/false)")
.option("--min-priority <level>", "Minimum priority to deliver (low, normal, high, urgent)")
.option("--group-by <key>", "Group notifications by: channel, agent, category")
.option("--quiet-start <time>", "Quiet hours start (HH:MM)")
.option("--quiet-end <time>", "Quiet hours end (HH:MM)")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["moltbot notifications prefs", "Show current preferences."],
["moltbot notifications prefs --enabled true", "Enable notifications."],
["moltbot notifications prefs --min-priority high", "Only deliver high/urgent."],
['moltbot notifications prefs --quiet-start "22:00" --quiet-end "08:00"', "Set quiet hours."],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await notificationsPrefsCommand(
{
json: Boolean(opts.json),
enabled: opts.enabled as string | undefined,
minPriority: opts.minPriority as string | undefined,
groupBy: opts.groupBy as string | undefined,
quietStart: opts.quietStart as string | undefined,
quietEnd: opts.quietEnd as string | undefined,
},
defaultRuntime,
);
});
});
}

View File

@ -1,6 +1,10 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { healthCommand } from "../../commands/health.js"; import { healthCommand } from "../../commands/health.js";
import { sessionsCommand } from "../../commands/sessions.js"; import { sessionsCommand } from "../../commands/sessions.js";
import {
sessionsBookmarkCommand,
sessionsSearchCommand,
} from "../../commands/sessions-search.js";
import { statusCommand } from "../../commands/status.js"; import { statusCommand } from "../../commands/status.js";
import { setVerbose } from "../../globals.js"; import { setVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
@ -143,4 +147,62 @@ export function registerStatusHealthSessionsCommands(program: Command) {
defaultRuntime, defaultRuntime,
); );
}); });
program
.command("search")
.description("Full-text search across session transcripts")
.argument("<query>", "Search query")
.option("--agent <id>", "Filter to a specific agent")
.option("--channel <name>", "Filter by channel (e.g. telegram, discord)")
.option("--since <period>", "Only search messages after this time (e.g. 1h, 2d, 30m, 2024-01-01)")
.option("--limit <n>", "Maximum results to return", "50")
.option("--json", "Output as JSON", false)
.option("--bookmarks", "List bookmarked messages instead of searching", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
['moltbot search "API key"', "Search all transcripts for 'API key'."],
['moltbot search "deploy" --agent pi', "Search only the pi agent."],
['moltbot search "error" --since 1d', "Errors in the last 24 hours."],
['moltbot search "meeting" --channel telegram', "Search Telegram messages."],
['moltbot search "bug" --limit 10 --json', "JSON output, max 10 results."],
["moltbot search --bookmarks", "List all bookmarked messages."],
])}`,
)
.action(async (query, opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await sessionsSearchCommand(
{
query: String(query),
agent: opts.agent as string | undefined,
channel: opts.channel as string | undefined,
since: opts.since as string | undefined,
limit: opts.limit ? Number.parseInt(String(opts.limit), 10) : 50,
json: Boolean(opts.json),
bookmarks: Boolean(opts.bookmarks),
},
defaultRuntime,
);
});
});
program
.command("bookmark")
.description("Manage message bookmarks")
.option("--add <ref>", "Bookmark a message (format: agentId:sessionKey:lineNumber)")
.option("--remove <ref>", "Remove a bookmark")
.option("--list", "List all bookmarks", true)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await sessionsBookmarkCommand(
{
add: opts.add as string | undefined,
remove: opts.remove as string | undefined,
list: true,
},
defaultRuntime,
);
});
});
} }

View File

@ -0,0 +1,148 @@
import type { Command } from "commander";
import {
templatesAddCommand,
templatesListCommand,
templatesRemoveCommand,
templatesShowCommand,
templatesUpdateCommand,
} from "../../commands/templates.js";
import { defaultRuntime } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
export function registerTemplatesCommands(program: Command) {
const templates = program
.command("templates")
.description("Manage response templates and quick replies");
templates
.command("list")
.description("List all response templates")
.option("--json", "Output as JSON", false)
.option("--agent <id>", "Filter templates available to a specific agent")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["moltbot templates list", "List all templates."],
["moltbot templates list --json", "JSON output."],
["moltbot templates list --agent pi", "Templates available to the pi agent."],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await templatesListCommand(
{
json: Boolean(opts.json),
agent: opts.agent as string | undefined,
},
defaultRuntime,
);
});
});
templates
.command("add")
.description("Add a new response template")
.requiredOption("--id <id>", "Unique template identifier")
.requiredOption("--name <name>", "Human-readable template name")
.requiredOption("--content <text>", "Template content (supports {variable} placeholders)")
.option("--agents <ids>", "Comma-separated agent ids this template applies to")
.option(
"--channels <overrides>",
"Channel-specific content overrides (format: channel:content,...)",
)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
[
'moltbot templates add --id greeting --name "Greeting" --content "Hello {senderName}! How can I help you today?"',
"Add a greeting template.",
],
[
'moltbot templates add --id away --name "Away" --content "I am currently away. I will respond when available." --agents pi',
"Agent-specific template.",
],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await templatesAddCommand(
{
id: String(opts.id),
name: String(opts.name),
content: String(opts.content),
agents: opts.agents as string | undefined,
channels: opts.channels as string | undefined,
},
defaultRuntime,
);
});
});
templates
.command("remove")
.description("Remove a response template")
.argument("<id>", "Template id to remove")
.action(async (id) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await templatesRemoveCommand({ id: String(id) }, defaultRuntime);
});
});
templates
.command("show")
.description("Show a template with optional variable expansion")
.argument("<id>", "Template id or name")
.option("--channel <name>", "Resolve channel-specific content")
.option("--expand", "Expand variables in the template", false)
.option("--vars <pairs>", "Variable values for expansion (format: key=value,...)")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["moltbot templates show greeting", "Show the greeting template."],
[
'moltbot templates show greeting --expand --vars "senderName=Alice"',
"Expand variables.",
],
["moltbot templates show greeting --channel telegram", "Show Telegram-specific content."],
])}`,
)
.action(async (id, opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await templatesShowCommand(
{
id: String(id),
channel: opts.channel as string | undefined,
expand: Boolean(opts.expand),
vars: opts.vars as string | undefined,
},
defaultRuntime,
);
});
});
templates
.command("update")
.description("Update an existing response template")
.argument("<id>", "Template id to update")
.option("--name <name>", "New name")
.option("--content <text>", "New content")
.option("--agents <ids>", "New comma-separated agent ids")
.action(async (id, opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await templatesUpdateCommand(
{
id: String(id),
name: opts.name as string | undefined,
content: opts.content as string | undefined,
agents: opts.agents as string | undefined,
},
defaultRuntime,
);
});
});
}

201
src/commands/backup.test.ts Normal file
View File

@ -0,0 +1,201 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { backupCreateCommand, backupExportCommand, backupRestoreCommand } from "./backup.js";
function createRuntime() {
const logs: string[] = [];
const errors: string[] = [];
return {
log: (...args: unknown[]) => logs.push(args.map(String).join(" ")),
error: (...args: unknown[]) => errors.push(args.map(String).join(" ")),
exit: (code: number) => {
throw new Error(`exit(${code})`);
},
logs,
errors,
};
}
describe("backup create & restore", () => {
let stateDir: string;
let backupDir: string;
let origEnv: string | undefined;
beforeEach(async () => {
stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-backup-state-"));
backupDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-backup-out-"));
origEnv = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = stateDir;
// Create some test state
await fs.promises.mkdir(path.join(stateDir, "agents", "pi", "sessions"), { recursive: true });
await fs.promises.writeFile(
path.join(stateDir, "agents", "pi", "sessions", "main.jsonl"),
JSON.stringify({ role: "user", content: "Hello", timestamp: Date.now() }) + "\n",
"utf-8",
);
await fs.promises.writeFile(
path.join(stateDir, "moltbot.json"),
JSON.stringify({ agents: {} }),
"utf-8",
);
});
afterEach(async () => {
if (origEnv !== undefined) {
process.env.CLAWDBOT_STATE_DIR = origEnv;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
await fs.promises.rm(stateDir, { recursive: true, force: true });
await fs.promises.rm(backupDir, { recursive: true, force: true });
});
it("creates a backup with manifest", async () => {
const outputDir = path.join(backupDir, "test-backup");
const runtime = createRuntime();
await backupCreateCommand({ output: outputDir }, runtime);
expect(runtime.logs.some((l) => l.includes("Backup created"))).toBe(true);
expect(fs.existsSync(path.join(outputDir, "backup-manifest.json"))).toBe(true);
const manifest = JSON.parse(
fs.readFileSync(path.join(outputDir, "backup-manifest.json"), "utf-8"),
);
expect(manifest.files.length).toBeGreaterThan(0);
expect(manifest.includesCredentials).toBe(false);
});
it("creates backup as JSON", async () => {
const outputDir = path.join(backupDir, "json-backup");
const runtime = createRuntime();
await backupCreateCommand({ output: outputDir, json: true }, runtime);
const output = JSON.parse(runtime.logs.join("\n"));
expect(output.fileCount).toBeGreaterThan(0);
expect(output.output).toBe(outputDir);
});
it("restores a backup", async () => {
// Create backup
const outputDir = path.join(backupDir, "restore-test");
const runtime1 = createRuntime();
await backupCreateCommand({ output: outputDir }, runtime1);
// Wipe state dir
await fs.promises.rm(path.join(stateDir, "agents"), { recursive: true, force: true });
expect(
fs.existsSync(path.join(stateDir, "agents", "pi", "sessions", "main.jsonl")),
).toBe(false);
// Restore
const runtime2 = createRuntime();
await backupRestoreCommand({ input: outputDir }, runtime2);
expect(runtime2.logs.some((l) => l.includes("Restored"))).toBe(true);
// Verify file was restored
expect(
fs.existsSync(path.join(stateDir, "agents", "pi", "sessions", "main.jsonl")),
).toBe(true);
});
it("dry-run restore shows files without changing them", async () => {
const outputDir = path.join(backupDir, "dryrun-test");
const runtime1 = createRuntime();
await backupCreateCommand({ output: outputDir }, runtime1);
// Remove a file
await fs.promises.rm(path.join(stateDir, "agents", "pi", "sessions", "main.jsonl"));
const runtime2 = createRuntime();
await backupRestoreCommand({ input: outputDir, dryRun: true }, runtime2);
expect(runtime2.logs.some((l) => l.includes("Dry run"))).toBe(true);
// File should NOT be restored in dry run
expect(
fs.existsSync(path.join(stateDir, "agents", "pi", "sessions", "main.jsonl")),
).toBe(false);
});
it("rejects invalid backup directory", async () => {
const runtime = createRuntime();
await backupRestoreCommand({ input: "/tmp/nonexistent-backup" }, runtime);
expect(runtime.errors.some((l) => l.includes("Not a valid backup"))).toBe(true);
});
});
describe("backup export", () => {
let stateDir: string;
let origEnv: string | undefined;
beforeEach(async () => {
stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-export-"));
origEnv = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = stateDir;
await fs.promises.mkdir(path.join(stateDir, "agents", "pi", "sessions"), { recursive: true });
const lines = [
JSON.stringify({ role: "user", content: "Hello", timestamp: "2024-01-15T10:00:00Z" }),
JSON.stringify({ role: "assistant", content: "Hi there!", timestamp: "2024-01-15T10:00:01Z" }),
];
await fs.promises.writeFile(
path.join(stateDir, "agents", "pi", "sessions", "main.jsonl"),
lines.join("\n"),
"utf-8",
);
});
afterEach(async () => {
if (origEnv !== undefined) {
process.env.CLAWDBOT_STATE_DIR = origEnv;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
await fs.promises.rm(stateDir, { recursive: true, force: true });
});
it("exports as markdown", async () => {
const outputPath = path.join(stateDir, "export.md");
const runtime = createRuntime();
await backupExportCommand({ format: "markdown", output: outputPath }, runtime);
expect(runtime.logs.some((l) => l.includes("Exported to"))).toBe(true);
const content = fs.readFileSync(outputPath, "utf-8");
expect(content).toContain("pi / main");
expect(content).toContain("Hello");
expect(content).toContain("Hi there!");
});
it("exports as jsonl", async () => {
const outputPath = path.join(stateDir, "export.jsonl");
const runtime = createRuntime();
await backupExportCommand({ format: "jsonl", output: outputPath }, runtime);
const content = fs.readFileSync(outputPath, "utf-8");
const lines = content.trim().split("\n");
expect(lines.length).toBe(2);
const first = JSON.parse(lines[0]);
expect(first.agentId).toBe("pi");
expect(first.content).toBe("Hello");
});
it("filters by agent", async () => {
// Add another agent's sessions
await fs.promises.mkdir(path.join(stateDir, "agents", "other", "sessions"), { recursive: true });
await fs.promises.writeFile(
path.join(stateDir, "agents", "other", "sessions", "chat.jsonl"),
JSON.stringify({ role: "user", content: "Other agent" }),
"utf-8",
);
const outputPath = path.join(stateDir, "export.jsonl");
const runtime = createRuntime();
await backupExportCommand({ format: "jsonl", output: outputPath, agent: "pi" }, runtime);
const content = fs.readFileSync(outputPath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
expect(lines.every((l) => JSON.parse(l).agentId === "pi")).toBe(true);
});
});

477
src/commands/backup.ts Normal file
View File

@ -0,0 +1,477 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { info, warn } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
export type BackupCreateOptions = {
output?: string;
includeCredentials?: boolean;
json?: boolean;
};
export type BackupRestoreOptions = {
input: string;
dryRun?: boolean;
json?: boolean;
};
export type BackupExportOptions = {
format?: "markdown" | "json" | "jsonl";
output?: string;
agent?: string;
session?: string;
since?: string;
};
type BackupManifest = {
version: string;
createdAt: string;
hostname: string;
platform: string;
stateDir: string;
configPath: string;
includesCredentials: boolean;
files: BackupFileEntry[];
};
type BackupFileEntry = {
relativePath: string;
size: number;
sha256: string;
};
const BACKUP_MANIFEST = "backup-manifest.json";
function hashFile(filePath: string): string {
const data = fs.readFileSync(filePath);
return crypto.createHash("sha256").update(data).digest("hex");
}
async function collectFiles(
dir: string,
baseDir: string,
excludePatterns: string[] = [],
): Promise<{ relativePath: string; absolutePath: string }[]> {
const results: { relativePath: string; absolutePath: string }[] = [];
try {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const absPath = path.join(dir, entry.name);
const relPath = path.relative(baseDir, absPath);
// Skip excluded patterns
if (excludePatterns.some((p) => relPath.includes(p) || entry.name === p)) {
continue;
}
if (entry.isDirectory()) {
const subFiles = await collectFiles(absPath, baseDir, excludePatterns);
results.push(...subFiles);
} else if (entry.isFile()) {
results.push({ relativePath: relPath, absolutePath: absPath });
}
}
} catch {
// skip inaccessible directories
}
return results;
}
export async function backupCreateCommand(
opts: BackupCreateOptions,
runtime: RuntimeEnv,
): Promise<void> {
const stateDir = resolveStateDir();
const configPath = resolveConfigPath();
const includeCredentials = opts.includeCredentials ?? false;
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const defaultOutput = path.join(os.homedir(), `moltbot-backup-${timestamp}`);
const outputDir = opts.output ?? defaultOutput;
if (!opts.json) {
runtime.log(info("Creating backup..."));
runtime.log(info(`State dir: ${stateDir}`));
runtime.log(info(`Output: ${outputDir}`));
}
// Collect files to backup
const excludePatterns = [
".lock",
".tmp",
"node_modules",
".git",
"cache",
];
if (!includeCredentials) {
excludePatterns.push("credentials");
}
const files = await collectFiles(stateDir, stateDir, excludePatterns);
// Also include the config file if it's outside the state dir
const configInStateDir = configPath.startsWith(stateDir);
if (!configInStateDir && fs.existsSync(configPath)) {
files.push({
relativePath: path.basename(configPath),
absolutePath: configPath,
});
}
// Create output directory
await fs.promises.mkdir(outputDir, { recursive: true });
// Copy files
const manifest: BackupManifest = {
version: VERSION,
createdAt: new Date().toISOString(),
hostname: os.hostname(),
platform: process.platform,
stateDir,
configPath,
includesCredentials: includeCredentials,
files: [],
};
let totalSize = 0;
for (const file of files) {
const destPath = path.join(outputDir, file.relativePath);
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await fs.promises.copyFile(file.absolutePath, destPath);
const stat = await fs.promises.stat(file.absolutePath);
const sha256 = hashFile(file.absolutePath);
manifest.files.push({
relativePath: file.relativePath,
size: stat.size,
sha256,
});
totalSize += stat.size;
}
// Write manifest
await fs.promises.writeFile(
path.join(outputDir, BACKUP_MANIFEST),
JSON.stringify(manifest, null, 2),
"utf-8",
);
const fileSizeLabel = formatBytes(totalSize);
if (opts.json) {
runtime.log(
JSON.stringify(
{
output: outputDir,
fileCount: manifest.files.length,
totalSize,
totalSizeLabel: fileSizeLabel,
includesCredentials: includeCredentials,
manifest,
},
null,
2,
),
);
return;
}
const rich = isRich();
runtime.log("");
runtime.log(
rich
? theme.success(`Backup created: ${outputDir}`)
: `Backup created: ${outputDir}`,
);
runtime.log(info(`Files: ${manifest.files.length}`));
runtime.log(info(`Size: ${fileSizeLabel}`));
runtime.log(info(`Credentials: ${includeCredentials ? "included" : "excluded"}`));
if (!includeCredentials) {
runtime.log(warn("Tip: Use --include-credentials to include API keys and tokens."));
}
}
export async function backupRestoreCommand(
opts: BackupRestoreOptions,
runtime: RuntimeEnv,
): Promise<void> {
const inputDir = path.resolve(opts.input);
const manifestPath = path.join(inputDir, BACKUP_MANIFEST);
if (!fs.existsSync(manifestPath)) {
runtime.error(`Not a valid backup: missing ${BACKUP_MANIFEST} at ${inputDir}`);
return;
}
const manifest = JSON.parse(
fs.readFileSync(manifestPath, "utf-8"),
) as BackupManifest;
runtime.log(info(`Restoring backup from: ${inputDir}`));
runtime.log(info(`Backup version: ${manifest.version}`));
runtime.log(info(`Created: ${manifest.createdAt}`));
runtime.log(info(`Files: ${manifest.files.length}`));
runtime.log(info(`Includes credentials: ${manifest.includesCredentials}`));
const stateDir = resolveStateDir();
if (opts.dryRun) {
runtime.log("");
runtime.log(info("Dry run - files that would be restored:"));
for (const file of manifest.files) {
const destPath = path.join(stateDir, file.relativePath);
const exists = fs.existsSync(destPath);
runtime.log(` ${exists ? "overwrite" : "create"}: ${file.relativePath} (${formatBytes(file.size)})`);
}
return;
}
// Verify file integrity
let integrityOk = true;
for (const file of manifest.files) {
const srcPath = path.join(inputDir, file.relativePath);
if (!fs.existsSync(srcPath)) {
runtime.error(`Missing file in backup: ${file.relativePath}`);
integrityOk = false;
continue;
}
const sha256 = hashFile(srcPath);
if (sha256 !== file.sha256) {
runtime.error(`Integrity check failed for: ${file.relativePath}`);
integrityOk = false;
}
}
if (!integrityOk) {
runtime.error("Backup integrity check failed. Aborting restore.");
return;
}
// Restore files
let restored = 0;
for (const file of manifest.files) {
const srcPath = path.join(inputDir, file.relativePath);
const destPath = path.join(stateDir, file.relativePath);
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await fs.promises.copyFile(srcPath, destPath);
restored++;
}
runtime.log("");
runtime.log(
isRich()
? theme.success(`Restored ${restored} file(s) to ${stateDir}`)
: `Restored ${restored} file(s) to ${stateDir}`,
);
}
export async function backupExportCommand(
opts: BackupExportOptions,
runtime: RuntimeEnv,
): Promise<void> {
const stateDir = resolveStateDir();
const agentsDir = path.join(stateDir, "agents");
const format = opts.format ?? "markdown";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const ext = format === "markdown" ? "md" : format;
const outputPath = opts.output ?? path.join(os.homedir(), `moltbot-export-${timestamp}.${ext}`);
const sinceMs = opts.since ? parseSinceForExport(opts.since) : null;
const agentFilter = opts.agent;
const sessionFilter = opts.session;
runtime.log(info(`Exporting conversations as ${format}...`));
// Collect transcript files
type TranscriptInfo = {
agentId: string;
sessionKey: string;
filePath: string;
};
const transcripts: TranscriptInfo[] = [];
try {
const agents = await fs.promises.readdir(agentsDir, { withFileTypes: true });
for (const agentEntry of agents) {
if (!agentEntry.isDirectory()) continue;
if (agentFilter && agentEntry.name !== agentFilter) continue;
const sessionsDir = path.join(agentsDir, agentEntry.name, "sessions");
try {
const files = await fs.promises.readdir(sessionsDir, { withFileTypes: true });
for (const file of files) {
if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
const sessionKey = file.name.replace(/\.jsonl$/, "");
if (sessionFilter && sessionKey !== sessionFilter) continue;
transcripts.push({
agentId: agentEntry.name,
sessionKey,
filePath: path.join(sessionsDir, file.name),
});
}
} catch {
// no sessions dir
}
}
} catch {
runtime.error("No agents directory found.");
return;
}
if (transcripts.length === 0) {
runtime.log("No transcripts to export.");
return;
}
const outputParts: string[] = [];
for (const transcript of transcripts) {
const messages = await readTranscriptMessages(transcript.filePath, sinceMs);
if (messages.length === 0) continue;
if (format === "markdown") {
outputParts.push(`## ${transcript.agentId} / ${transcript.sessionKey}\n`);
for (const msg of messages) {
const ts = msg.timestamp ? `_${msg.timestamp}_` : "";
outputParts.push(`**${msg.role}** ${ts}\n\n${msg.content}\n\n---\n`);
}
} else if (format === "json") {
outputParts.push(
JSON.stringify(
{
agentId: transcript.agentId,
sessionKey: transcript.sessionKey,
messages,
},
null,
2,
),
);
} else {
// jsonl
for (const msg of messages) {
outputParts.push(
JSON.stringify({
agentId: transcript.agentId,
sessionKey: transcript.sessionKey,
...msg,
}),
);
}
}
}
const content =
format === "json"
? JSON.stringify(JSON.parse(`[${outputParts.join(",")}]`), null, 2)
: outputParts.join("\n");
await fs.promises.writeFile(outputPath, content, "utf-8");
runtime.log(
isRich()
? theme.success(`Exported to: ${outputPath}`)
: `Exported to: ${outputPath}`,
);
runtime.log(info(`Transcripts: ${transcripts.length}`));
}
// ── Helpers ──
type ParsedMessage = {
role: string;
content: string;
timestamp?: string;
};
async function readTranscriptMessages(
filePath: string,
sinceMs: number | null,
): Promise<ParsedMessage[]> {
const messages: ParsedMessage[] = [];
const readline = await import("node:readline");
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
const rl = readline.createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of rl) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as Record<string, unknown>;
const role = String(entry.role ?? "unknown");
const content = extractContent(entry);
if (!content) continue;
if (sinceMs !== null) {
const ts = entry.timestamp ?? entry.ts ?? entry.createdAt;
if (typeof ts === "number" && ts < sinceMs) continue;
if (typeof ts === "string") {
const tsMs = new Date(ts).getTime();
if (!Number.isNaN(tsMs) && tsMs < sinceMs) continue;
}
}
messages.push({
role,
content,
timestamp: resolveTimestamp(entry),
});
} catch {
// skip
}
}
return messages;
}
function extractContent(entry: Record<string, unknown>): string {
if (typeof entry.content === "string") return entry.content;
if (Array.isArray(entry.content)) {
return (entry.content as Array<Record<string, unknown>>)
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block.text === "string") return block.text;
return "";
})
.filter(Boolean)
.join(" ");
}
if (typeof entry.body === "string") return entry.body;
if (typeof entry.text === "string") return entry.text;
return "";
}
function resolveTimestamp(entry: Record<string, unknown>): string | undefined {
const ts = entry.timestamp ?? entry.ts ?? entry.createdAt;
if (typeof ts === "string") return ts;
if (typeof ts === "number") return new Date(ts).toISOString();
return undefined;
}
function parseSinceForExport(since: string): number | null {
const match = since.match(/^(\d+)([mhd])$/);
if (match) {
const value = Number.parseInt(match[1], 10);
const unit = match[2];
const now = Date.now();
if (unit === "m") return now - value * 60_000;
if (unit === "h") return now - value * 3_600_000;
if (unit === "d") return now - value * 86_400_000;
}
const parsed = new Date(since).getTime();
if (!Number.isNaN(parsed)) return parsed;
return null;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}

View File

@ -0,0 +1,180 @@
import { getNotificationManager } from "../notifications/manager.js";
import type { NotificationCategory, NotificationPriority } from "../notifications/types.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
export type NotificationsListOptions = {
json?: boolean;
unread?: boolean;
category?: string;
channel?: string;
agent?: string;
limit?: number;
};
export async function notificationsListCommand(
opts: NotificationsListOptions,
runtime: RuntimeEnv,
): Promise<void> {
const manager = getNotificationManager();
const notifications = opts.unread
? manager.getUnread()
: manager.getAll({
category: opts.category as NotificationCategory | undefined,
channel: opts.channel,
agentId: opts.agent,
limit: opts.limit,
});
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: notifications.length,
unreadOnly: opts.unread ?? false,
notifications,
},
null,
2,
),
);
return;
}
const unread = manager.getUnread().length;
runtime.log(info(`Notifications: ${notifications.length} total, ${unread} unread`));
if (notifications.length === 0) {
runtime.log("No notifications.");
return;
}
const rich = isRich();
runtime.log("");
for (const n of notifications) {
const readMark = n.read ? " " : "*";
const priorityLabel = n.priority === "urgent" ? " [URGENT]" : n.priority === "high" ? " [HIGH]" : "";
const ts = new Date(n.createdAt).toLocaleString();
const channel = n.channel ? ` (${n.channel})` : "";
if (rich) {
const titleLine = `${readMark} ${theme.accent(n.title)}${priorityLabel}${channel}`;
runtime.log(titleLine);
runtime.log(` ${theme.muted(ts)} ${theme.muted(`[${n.category}]`)}`);
runtime.log(` ${n.body}`);
if (n.actions?.length) {
const actionLabels = n.actions.map((a) => `[${a.label}]`).join(" ");
runtime.log(` ${theme.info(actionLabels)}`);
}
} else {
runtime.log(`${readMark} ${n.title}${priorityLabel}${channel}`);
runtime.log(` ${ts} [${n.category}]`);
runtime.log(` ${n.body}`);
}
runtime.log("");
}
}
export async function notificationsReadCommand(
opts: { id?: string; all?: boolean },
runtime: RuntimeEnv,
): Promise<void> {
const manager = getNotificationManager();
if (opts.all) {
const count = await manager.markAllRead();
runtime.log(info(`Marked ${count} notification(s) as read.`));
return;
}
if (opts.id) {
const ok = await manager.markRead(opts.id);
if (ok) {
runtime.log(info(`Notification ${opts.id} marked as read.`));
} else {
runtime.error(`Notification "${opts.id}" not found.`);
}
return;
}
runtime.error("Specify --id <id> or --all to mark notifications as read.");
}
export async function notificationsClearCommand(runtime: RuntimeEnv): Promise<void> {
const manager = getNotificationManager();
const count = await manager.clear();
runtime.log(info(`Cleared ${count} notification(s).`));
}
export async function notificationsPrefsCommand(
opts: {
json?: boolean;
enabled?: string;
minPriority?: string;
groupBy?: string;
quietStart?: string;
quietEnd?: string;
},
runtime: RuntimeEnv,
): Promise<void> {
const manager = getNotificationManager();
// If any setter is provided, update preferences
const hasUpdates =
opts.enabled !== undefined ||
opts.minPriority !== undefined ||
opts.groupBy !== undefined ||
opts.quietStart !== undefined ||
opts.quietEnd !== undefined;
if (hasUpdates) {
const patch: Record<string, unknown> = {};
if (opts.enabled !== undefined) {
patch.enabled = opts.enabled === "true" || opts.enabled === "1";
}
if (opts.minPriority !== undefined) {
patch.minPriority = opts.minPriority as NotificationPriority;
}
if (opts.groupBy !== undefined) {
patch.groupBy = opts.groupBy;
}
if (opts.quietStart || opts.quietEnd) {
const current = manager.getPreferences().quietHours ?? {
enabled: true,
start: "22:00",
end: "08:00",
};
patch.quietHours = {
...current,
enabled: true,
start: opts.quietStart ?? current.start,
end: opts.quietEnd ?? current.end,
};
}
await manager.updatePreferences(patch);
runtime.log(info("Notification preferences updated."));
}
const prefs = manager.getPreferences();
if (opts.json) {
runtime.log(JSON.stringify(prefs, null, 2));
return;
}
const rich = isRich();
runtime.log(rich ? theme.heading("Notification Preferences") : "Notification Preferences");
runtime.log(` Enabled: ${prefs.enabled}`);
runtime.log(` Min priority: ${prefs.minPriority}`);
runtime.log(` Group by: ${prefs.groupBy ?? "channel"}`);
if (prefs.quietHours?.enabled) {
runtime.log(` Quiet hours: ${prefs.quietHours.start} - ${prefs.quietHours.end}`);
} else {
runtime.log(" Quiet hours: disabled");
}
if (prefs.categories?.length) {
runtime.log(` Categories: ${prefs.categories.join(", ")}`);
}
}

View File

@ -0,0 +1,177 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
loadBookmarks,
saveBookmarks,
sessionsBookmarkCommand,
sessionsSearchCommand,
} from "./sessions-search.js";
function createRuntime() {
const logs: string[] = [];
const errors: string[] = [];
return {
log: (...args: unknown[]) => logs.push(args.map(String).join(" ")),
error: (...args: unknown[]) => errors.push(args.map(String).join(" ")),
exit: (code: number) => {
throw new Error(`exit(${code})`);
},
logs,
errors,
};
}
describe("sessionsSearchCommand", () => {
let tmpDir: string;
let origEnv: string | undefined;
beforeEach(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-search-test-"));
origEnv = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = tmpDir;
});
afterEach(async () => {
if (origEnv !== undefined) {
process.env.CLAWDBOT_STATE_DIR = origEnv;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
await fs.promises.rm(tmpDir, { recursive: true, force: true });
});
async function writeTranscript(agentId: string, sessionKey: string, lines: object[]) {
const dir = path.join(tmpDir, "agents", agentId, "sessions");
await fs.promises.mkdir(dir, { recursive: true });
const content = lines.map((l) => JSON.stringify(l)).join("\n");
await fs.promises.writeFile(path.join(dir, `${sessionKey}.jsonl`), content, "utf-8");
}
it("finds matching messages in transcripts", async () => {
await writeTranscript("pi", "main", [
{ role: "user", content: "Hello world", timestamp: Date.now() },
{ role: "assistant", content: "Hi there", timestamp: Date.now() },
{ role: "user", content: "Deploy the API", timestamp: Date.now() },
]);
const runtime = createRuntime();
await sessionsSearchCommand({ query: "deploy" }, runtime);
expect(runtime.logs.some((l) => l.includes("deploy") || l.includes("Deploy"))).toBe(true);
expect(runtime.logs.some((l) => l.includes("1 result"))).toBe(true);
});
it("returns no results for non-matching query", async () => {
await writeTranscript("pi", "main", [
{ role: "user", content: "Hello world", timestamp: Date.now() },
]);
const runtime = createRuntime();
await sessionsSearchCommand({ query: "nonexistent-query-xyz" }, runtime);
expect(runtime.logs.some((l) => l.includes("No matches"))).toBe(true);
});
it("filters by agent", async () => {
await writeTranscript("pi", "main", [
{ role: "user", content: "alpha message", timestamp: Date.now() },
]);
await writeTranscript("other", "main", [
{ role: "user", content: "alpha message", timestamp: Date.now() },
]);
const runtime = createRuntime();
await sessionsSearchCommand({ query: "alpha", agent: "pi" }, runtime);
expect(runtime.logs.some((l) => l.includes("1 result"))).toBe(true);
});
it("outputs JSON when requested", async () => {
await writeTranscript("pi", "main", [
{ role: "user", content: "test message", timestamp: "2024-01-15T10:00:00Z" },
]);
const runtime = createRuntime();
await sessionsSearchCommand({ query: "test", json: true }, runtime);
const output = runtime.logs.join("\n");
const parsed = JSON.parse(output);
expect(parsed.count).toBe(1);
expect(parsed.results[0].content).toContain("test message");
});
it("respects limit", async () => {
const lines = Array.from({ length: 20 }, (_, i) => ({
role: "user",
content: `matching line ${i}`,
timestamp: Date.now(),
}));
await writeTranscript("pi", "main", lines);
const runtime = createRuntime();
await sessionsSearchCommand({ query: "matching", limit: 5, json: true }, runtime);
const parsed = JSON.parse(runtime.logs.join("\n"));
expect(parsed.count).toBe(5);
});
it("handles empty state directory", async () => {
const runtime = createRuntime();
await sessionsSearchCommand({ query: "anything" }, runtime);
expect(runtime.logs.some((l) => l.includes("No session transcripts"))).toBe(true);
});
it("filters by since period", async () => {
const now = Date.now();
await writeTranscript("pi", "main", [
{ role: "user", content: "old message", timestamp: now - 86_400_000 * 3 },
{ role: "user", content: "recent message", timestamp: now - 3_600_000 },
]);
const runtime = createRuntime();
await sessionsSearchCommand({ query: "message", since: "1d" }, runtime);
expect(runtime.logs.some((l) => l.includes("1 result"))).toBe(true);
});
});
describe("bookmarks", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-bm-test-"));
});
afterEach(async () => {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
});
it("loads empty bookmarks from missing file", () => {
const bm = loadBookmarks(tmpDir);
expect(bm.size).toBe(0);
});
it("saves and loads bookmarks", async () => {
const bm = new Set(["pi:main:5", "pi:chat:10"]);
await saveBookmarks(tmpDir, bm);
const loaded = loadBookmarks(tmpDir);
expect(loaded.size).toBe(2);
expect(loaded.has("pi:main:5")).toBe(true);
});
it("bookmark command adds and lists", async () => {
const origEnv = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = tmpDir;
try {
const runtime = createRuntime();
await sessionsBookmarkCommand({ add: "pi:main:42" }, runtime);
expect(runtime.logs.some((l) => l.includes("Bookmarked"))).toBe(true);
const runtime2 = createRuntime();
await sessionsBookmarkCommand({ list: true }, runtime2);
expect(runtime2.logs.some((l) => l.includes("pi:main:42"))).toBe(true);
} finally {
if (origEnv !== undefined) {
process.env.CLAWDBOT_STATE_DIR = origEnv;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
}
});
});

View File

@ -0,0 +1,324 @@
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { resolveStateDir } from "../config/paths.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
export type SessionSearchOptions = {
query: string;
agent?: string;
channel?: string;
since?: string;
limit?: number;
json?: boolean;
bookmarks?: boolean;
};
type SearchResult = {
file: string;
agentId: string;
sessionKey: string;
line: number;
role: string;
content: string;
timestamp?: string;
channel?: string;
bookmarked?: boolean;
};
const BOOKMARKS_FILENAME = "bookmarks.json";
function resolveBookmarksPath(stateDir: string): string {
return path.join(stateDir, BOOKMARKS_FILENAME);
}
export function loadBookmarks(stateDir: string): Set<string> {
const bookmarksPath = resolveBookmarksPath(stateDir);
try {
const raw = fs.readFileSync(bookmarksPath, "utf-8");
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return new Set(parsed as string[]);
} catch {
// no bookmarks file yet
}
return new Set();
}
export async function saveBookmarks(stateDir: string, bookmarks: Set<string>): Promise<void> {
const bookmarksPath = resolveBookmarksPath(stateDir);
await fs.promises.mkdir(path.dirname(bookmarksPath), { recursive: true });
await fs.promises.writeFile(bookmarksPath, JSON.stringify([...bookmarks], null, 2), "utf-8");
}
async function searchTranscriptFile(
filePath: string,
query: string,
sinceMs: number | null,
channelFilter: string | null,
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
const parts = filePath.split(path.sep);
// Path: .../agents/<agentId>/sessions/<sessionKey>.jsonl
const agentsIdx = parts.lastIndexOf("agents");
const agentId = agentsIdx >= 0 && parts[agentsIdx + 1] ? parts[agentsIdx + 1] : "unknown";
const basename = path.basename(filePath, ".jsonl");
let lineNum = 0;
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
const rl = readline.createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of rl) {
lineNum++;
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as Record<string, unknown>;
const role = String(entry.role ?? "unknown");
const content = extractContent(entry);
if (!content) continue;
// Filter by timestamp
if (sinceMs !== null) {
const ts = entry.timestamp ?? entry.ts ?? entry.createdAt;
if (typeof ts === "number" && ts < sinceMs) continue;
if (typeof ts === "string") {
const tsMs = new Date(ts).getTime();
if (!Number.isNaN(tsMs) && tsMs < sinceMs) continue;
}
}
// Filter by channel
if (channelFilter) {
const ch = String(entry.channel ?? entry.provider ?? "");
if (ch && !ch.toLowerCase().includes(channelFilter.toLowerCase())) continue;
}
// Full-text match
if (content.toLowerCase().includes(lowerQuery)) {
results.push({
file: filePath,
agentId,
sessionKey: basename,
line: lineNum,
role,
content,
timestamp: resolveTimestamp(entry),
channel: String(entry.channel ?? entry.provider ?? ""),
});
}
} catch {
// skip unparseable lines
}
}
return results;
}
function extractContent(entry: Record<string, unknown>): string {
if (typeof entry.content === "string") return entry.content;
if (Array.isArray(entry.content)) {
return (entry.content as Array<Record<string, unknown>>)
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block.text === "string") return block.text;
return "";
})
.filter(Boolean)
.join(" ");
}
if (typeof entry.body === "string") return entry.body;
if (typeof entry.text === "string") return entry.text;
return "";
}
function resolveTimestamp(entry: Record<string, unknown>): string | undefined {
const ts = entry.timestamp ?? entry.ts ?? entry.createdAt;
if (typeof ts === "string") return ts;
if (typeof ts === "number") return new Date(ts).toISOString();
return undefined;
}
function parseSince(since: string): number | null {
// Support formats: "1h", "2d", "30m", "2024-01-01", ISO date strings
const match = since.match(/^(\d+)([mhd])$/);
if (match) {
const value = Number.parseInt(match[1], 10);
const unit = match[2];
const now = Date.now();
if (unit === "m") return now - value * 60_000;
if (unit === "h") return now - value * 3_600_000;
if (unit === "d") return now - value * 86_400_000;
}
const parsed = new Date(since).getTime();
if (!Number.isNaN(parsed)) return parsed;
return null;
}
function truncateContent(content: string, maxLen = 120): string {
if (content.length <= maxLen) return content;
return `${content.slice(0, maxLen)}...`;
}
async function findTranscriptFiles(sessionsDir: string): Promise<string[]> {
const files: string[] = [];
try {
const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(path.join(sessionsDir, entry.name));
}
}
} catch {
// dir doesn't exist
}
return files;
}
async function findAllTranscriptFiles(stateDir: string, agentFilter?: string): Promise<string[]> {
const agentsDir = path.join(stateDir, "agents");
const allFiles: string[] = [];
try {
const agents = await fs.promises.readdir(agentsDir, { withFileTypes: true });
for (const agentEntry of agents) {
if (!agentEntry.isDirectory()) continue;
if (agentFilter && agentEntry.name !== agentFilter) continue;
const sessionsDir = path.join(agentsDir, agentEntry.name, "sessions");
const files = await findTranscriptFiles(sessionsDir);
allFiles.push(...files);
}
} catch {
// agents dir doesn't exist
}
return allFiles;
}
export async function sessionsSearchCommand(
opts: SessionSearchOptions,
runtime: RuntimeEnv,
): Promise<void> {
const stateDir = resolveStateDir();
const sinceMs = opts.since ? parseSince(opts.since) : null;
const limit = opts.limit ?? 50;
const bookmarks = loadBookmarks(stateDir);
if (opts.bookmarks) {
// List bookmarked messages
if (bookmarks.size === 0) {
runtime.log("No bookmarked messages.");
return;
}
if (opts.json) {
runtime.log(JSON.stringify([...bookmarks], null, 2));
} else {
runtime.log(info("Bookmarked messages:"));
for (const bm of bookmarks) {
runtime.log(` ${bm}`);
}
}
return;
}
const files = await findAllTranscriptFiles(stateDir, opts.agent);
if (files.length === 0) {
runtime.log("No session transcripts found.");
return;
}
const allResults: SearchResult[] = [];
for (const file of files) {
if (allResults.length >= limit) break;
const results = await searchTranscriptFile(file, opts.query, sinceMs, opts.channel ?? null);
for (const r of results) {
r.bookmarked = bookmarks.has(`${r.agentId}:${r.sessionKey}:${r.line}`);
allResults.push(r);
if (allResults.length >= limit) break;
}
}
if (opts.json) {
runtime.log(
JSON.stringify(
{
query: opts.query,
count: allResults.length,
limit,
results: allResults.map((r) => ({
agentId: r.agentId,
sessionKey: r.sessionKey,
line: r.line,
role: r.role,
content: r.content,
timestamp: r.timestamp,
channel: r.channel,
bookmarked: r.bookmarked,
})),
},
null,
2,
),
);
return;
}
runtime.log(info(`Search: "${opts.query}" across ${files.length} transcript(s)`));
runtime.log(info(`Found: ${allResults.length} result(s) (limit: ${limit})`));
if (allResults.length === 0) {
runtime.log("No matches found.");
return;
}
const rich = isRich();
for (const result of allResults) {
const bookmark = result.bookmarked ? " *" : "";
const ts = result.timestamp ? ` ${result.timestamp}` : "";
const ch = result.channel ? ` [${result.channel}]` : "";
const header = `${result.agentId}/${result.sessionKey}:${result.line}${ch}${ts}${bookmark}`;
const content = truncateContent(result.content.replaceAll("\n", " "));
if (rich) {
runtime.log(`${theme.accent(header)}`);
runtime.log(` ${theme.muted(result.role)}: ${content}`);
} else {
runtime.log(header);
runtime.log(` ${result.role}: ${content}`);
}
}
}
export async function sessionsBookmarkCommand(
opts: { add?: string; remove?: string; list?: boolean },
runtime: RuntimeEnv,
): Promise<void> {
const stateDir = resolveStateDir();
const bookmarks = loadBookmarks(stateDir);
if (opts.add) {
bookmarks.add(opts.add);
await saveBookmarks(stateDir, bookmarks);
runtime.log(info(`Bookmarked: ${opts.add}`));
return;
}
if (opts.remove) {
bookmarks.delete(opts.remove);
await saveBookmarks(stateDir, bookmarks);
runtime.log(info(`Removed bookmark: ${opts.remove}`));
return;
}
if (bookmarks.size === 0) {
runtime.log("No bookmarked messages.");
return;
}
runtime.log(info(`Bookmarks (${bookmarks.size}):`));
for (const bm of bookmarks) {
runtime.log(` ${bm}`);
}
}

View File

@ -0,0 +1,225 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
expandTemplateVariables,
findTemplate,
loadTemplates,
resolveTemplateContent,
templatesAddCommand,
templatesListCommand,
templatesRemoveCommand,
templatesShowCommand,
templatesUpdateCommand,
type ResponseTemplate,
} from "./templates.js";
function createRuntime() {
const logs: string[] = [];
const errors: string[] = [];
return {
log: (...args: unknown[]) => logs.push(args.map(String).join(" ")),
error: (...args: unknown[]) => errors.push(args.map(String).join(" ")),
exit: (code: number) => {
throw new Error(`exit(${code})`);
},
logs,
errors,
};
}
describe("expandTemplateVariables", () => {
it("expands built-in date variables", () => {
const result = expandTemplateVariables("Today is {date}");
expect(result).toMatch(/Today is \d{4}-\d{2}-\d{2}/);
});
it("expands custom variables", () => {
const result = expandTemplateVariables("Hello {senderName}!", { senderName: "Alice" });
expect(result).toBe("Hello Alice!");
});
it("leaves unresolved variables as-is", () => {
const result = expandTemplateVariables("Hello {unknown}!");
expect(result).toBe("Hello {unknown}!");
});
it("handles multiple variables", () => {
const result = expandTemplateVariables("{greeting} {name}, today is {date}", {
greeting: "Hi",
name: "Bob",
});
expect(result).toMatch(/Hi Bob, today is \d{4}-\d{2}-\d{2}/);
});
});
describe("findTemplate", () => {
const templates: ResponseTemplate[] = [
{
id: "greeting",
name: "Welcome Greeting",
content: "Hello!",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
{
id: "away",
name: "Away Message",
content: "I am away.",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
];
it("finds by exact id", () => {
expect(findTemplate(templates, "greeting")?.id).toBe("greeting");
});
it("finds by case-insensitive name", () => {
expect(findTemplate(templates, "away message")?.id).toBe("away");
});
it("returns undefined for non-existent template", () => {
expect(findTemplate(templates, "nonexistent")).toBeUndefined();
});
});
describe("resolveTemplateContent", () => {
it("returns default content when no channel override", () => {
const template: ResponseTemplate = {
id: "test",
name: "Test",
content: "Default content",
createdAt: "",
updatedAt: "",
};
expect(resolveTemplateContent(template)).toBe("Default content");
expect(resolveTemplateContent(template, "telegram")).toBe("Default content");
});
it("returns channel-specific content when available", () => {
const template: ResponseTemplate = {
id: "test",
name: "Test",
content: "Default",
channels: { telegram: "Telegram version", discord: "Discord version" },
createdAt: "",
updatedAt: "",
};
expect(resolveTemplateContent(template, "telegram")).toBe("Telegram version");
expect(resolveTemplateContent(template, "discord")).toBe("Discord version");
expect(resolveTemplateContent(template, "slack")).toBe("Default");
});
});
describe("templates CLI commands", () => {
let tmpDir: string;
let origEnv: string | undefined;
beforeEach(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-templates-test-"));
origEnv = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = tmpDir;
});
afterEach(async () => {
if (origEnv !== undefined) {
process.env.CLAWDBOT_STATE_DIR = origEnv;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
await fs.promises.rm(tmpDir, { recursive: true, force: true });
});
it("lists empty templates", async () => {
const runtime = createRuntime();
await templatesListCommand({}, runtime);
expect(runtime.logs.some((l) => l.includes("No response templates"))).toBe(true);
});
it("adds a template", async () => {
const runtime = createRuntime();
await templatesAddCommand(
{ id: "greet", name: "Greeting", content: "Hello {name}!" },
runtime,
);
expect(runtime.logs.some((l) => l.includes("added"))).toBe(true);
const templates = loadTemplates(tmpDir);
expect(templates).toHaveLength(1);
expect(templates[0].id).toBe("greet");
expect(templates[0].variables).toEqual(["name"]);
});
it("prevents duplicate ids", async () => {
const runtime = createRuntime();
await templatesAddCommand({ id: "greet", name: "Greeting", content: "Hello!" }, runtime);
const runtime2 = createRuntime();
await templatesAddCommand({ id: "greet", name: "Another", content: "Hi!" }, runtime2);
expect(runtime2.errors.some((l) => l.includes("already exists"))).toBe(true);
});
it("removes a template", async () => {
const runtime = createRuntime();
await templatesAddCommand({ id: "greet", name: "Greeting", content: "Hello!" }, runtime);
await templatesRemoveCommand({ id: "greet" }, runtime);
expect(runtime.logs.some((l) => l.includes("removed"))).toBe(true);
const templates = loadTemplates(tmpDir);
expect(templates).toHaveLength(0);
});
it("shows a template", async () => {
const runtime = createRuntime();
await templatesAddCommand({ id: "greet", name: "Greeting", content: "Hello {name}!" }, runtime);
const runtime2 = createRuntime();
await templatesShowCommand({ id: "greet" }, runtime2);
expect(runtime2.logs.some((l) => l.includes("Hello {name}!"))).toBe(true);
});
it("shows template with expansion", async () => {
const runtime = createRuntime();
await templatesAddCommand({ id: "greet", name: "Greeting", content: "Hello {name}!" }, runtime);
const runtime2 = createRuntime();
await templatesShowCommand({ id: "greet", expand: true, vars: "name=Alice" }, runtime2);
expect(runtime2.logs.some((l) => l.includes("Hello Alice!"))).toBe(true);
});
it("updates a template", async () => {
const runtime = createRuntime();
await templatesAddCommand({ id: "greet", name: "Greeting", content: "Hello!" }, runtime);
await templatesUpdateCommand({ id: "greet", content: "Hi there!" }, runtime);
const templates = loadTemplates(tmpDir);
expect(templates[0].content).toBe("Hi there!");
});
it("lists templates as JSON", async () => {
const runtime = createRuntime();
await templatesAddCommand({ id: "t1", name: "Template 1", content: "Content 1" }, runtime);
await templatesAddCommand({ id: "t2", name: "Template 2", content: "Content 2" }, runtime);
const runtime2 = createRuntime();
await templatesListCommand({ json: true }, runtime2);
const output = JSON.parse(runtime2.logs.join("\n"));
expect(output.count).toBe(2);
});
it("filters templates by agent", async () => {
const runtime = createRuntime();
await templatesAddCommand(
{ id: "t1", name: "For Pi", content: "Pi only", agents: "pi" },
runtime,
);
await templatesAddCommand({ id: "t2", name: "For All", content: "Everyone" }, runtime);
const runtime2 = createRuntime();
await templatesListCommand({ json: true, agent: "pi" }, runtime2);
const output = JSON.parse(runtime2.logs.join("\n"));
expect(output.count).toBe(2); // both: t1 has pi, t2 has no agent restriction
});
});

323
src/commands/templates.ts Normal file
View File

@ -0,0 +1,323 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
export type ResponseTemplate = {
id: string;
name: string;
content: string;
/** Optional per-channel content overrides (channel id -> content). */
channels?: Record<string, string>;
/** Optional agent filter: only available for these agents. */
agents?: string[];
/** Variable placeholders used in this template (informational). */
variables?: string[];
createdAt: string;
updatedAt: string;
};
type TemplateStore = {
templates: ResponseTemplate[];
};
const TEMPLATES_FILENAME = "templates.json";
function resolveTemplatesPath(stateDir: string): string {
return path.join(stateDir, TEMPLATES_FILENAME);
}
export function loadTemplates(stateDir?: string): ResponseTemplate[] {
const dir = stateDir ?? resolveStateDir();
const templatesPath = resolveTemplatesPath(dir);
try {
const raw = fs.readFileSync(templatesPath, "utf-8");
const parsed = JSON.parse(raw) as TemplateStore;
return parsed.templates ?? [];
} catch {
return [];
}
}
async function saveTemplates(templates: ResponseTemplate[], stateDir?: string): Promise<void> {
const dir = stateDir ?? resolveStateDir();
const templatesPath = resolveTemplatesPath(dir);
await fs.promises.mkdir(path.dirname(templatesPath), { recursive: true });
const store: TemplateStore = { templates };
await fs.promises.writeFile(templatesPath, JSON.stringify(store, null, 2), "utf-8");
}
/**
* Expand template variables in content.
* Supported variables: {senderName}, {date}, {time}, {channel}, {agentName}, and custom vars.
*/
export function expandTemplateVariables(
content: string,
vars: Record<string, string> = {},
): string {
const now = new Date();
const builtins: Record<string, string> = {
date: now.toISOString().split("T")[0],
time: now.toTimeString().split(" ")[0],
datetime: now.toISOString(),
year: String(now.getFullYear()),
month: String(now.getMonth() + 1).padStart(2, "0"),
day: String(now.getDate()).padStart(2, "0"),
...vars,
};
return content.replace(/\{(\w+)\}/g, (match, key: string) => {
const lower = key.toLowerCase();
return builtins[lower] ?? builtins[key] ?? match;
});
}
/**
* Find a template by id or name (case-insensitive partial match on name).
*/
export function findTemplate(
templates: ResponseTemplate[],
idOrName: string,
): ResponseTemplate | undefined {
// Exact id match first
const byId = templates.find((t) => t.id === idOrName);
if (byId) return byId;
// Case-insensitive name match
const lower = idOrName.toLowerCase();
return templates.find((t) => t.name.toLowerCase() === lower);
}
/**
* Resolve template content for a specific channel (falls back to default content).
*/
export function resolveTemplateContent(template: ResponseTemplate, channel?: string): string {
if (channel && template.channels?.[channel]) {
return template.channels[channel];
}
return template.content;
}
// ── CLI commands ──
export type TemplatesListOptions = {
json?: boolean;
agent?: string;
};
export async function templatesListCommand(
opts: TemplatesListOptions,
runtime: RuntimeEnv,
): Promise<void> {
let templates = loadTemplates();
if (opts.agent) {
templates = templates.filter(
(t) => !t.agents || t.agents.length === 0 || t.agents.includes(opts.agent!),
);
}
if (opts.json) {
runtime.log(JSON.stringify({ count: templates.length, templates }, null, 2));
return;
}
if (templates.length === 0) {
runtime.log("No response templates configured.");
runtime.log(info('Use "moltbot templates add" to create one.'));
return;
}
const rich = isRich();
runtime.log(info(`Response templates (${templates.length}):`));
runtime.log("");
for (const t of templates) {
const agents = t.agents?.length ? ` [${t.agents.join(", ")}]` : "";
const channels = t.channels ? ` (${Object.keys(t.channels).join(", ")} overrides)` : "";
const preview = t.content.length > 80 ? `${t.content.slice(0, 80)}...` : t.content;
if (rich) {
runtime.log(` ${theme.accent(t.id)} ${theme.heading(t.name)}${agents}${channels}`);
runtime.log(` ${theme.muted(preview)}`);
} else {
runtime.log(` ${t.id} ${t.name}${agents}${channels}`);
runtime.log(` ${preview}`);
}
runtime.log("");
}
}
export type TemplatesAddOptions = {
id: string;
name: string;
content: string;
agents?: string;
channels?: string;
};
export async function templatesAddCommand(
opts: TemplatesAddOptions,
runtime: RuntimeEnv,
): Promise<void> {
const templates = loadTemplates();
const existing = templates.find((t) => t.id === opts.id);
if (existing) {
runtime.error(`Template with id "${opts.id}" already exists. Use "templates update" to modify.`);
return;
}
// Extract variables from content
const varMatches = opts.content.match(/\{(\w+)\}/g) ?? [];
const variables = [...new Set(varMatches.map((m) => m.slice(1, -1)))];
const channelOverrides: Record<string, string> | undefined = opts.channels
? parseChannelOverrides(opts.channels)
: undefined;
const now = new Date().toISOString();
const template: ResponseTemplate = {
id: opts.id,
name: opts.name,
content: opts.content,
variables: variables.length > 0 ? variables : undefined,
agents: opts.agents ? opts.agents.split(",").map((a) => a.trim()) : undefined,
channels: channelOverrides,
createdAt: now,
updatedAt: now,
};
templates.push(template);
await saveTemplates(templates);
runtime.log(info(`Template "${opts.name}" (${opts.id}) added.`));
if (variables.length > 0) {
runtime.log(info(`Variables: ${variables.join(", ")}`));
}
}
export type TemplatesRemoveOptions = {
id: string;
};
export async function templatesRemoveCommand(
opts: TemplatesRemoveOptions,
runtime: RuntimeEnv,
): Promise<void> {
const templates = loadTemplates();
const idx = templates.findIndex((t) => t.id === opts.id);
if (idx === -1) {
runtime.error(`Template "${opts.id}" not found.`);
return;
}
const removed = templates.splice(idx, 1)[0];
await saveTemplates(templates);
runtime.log(info(`Template "${removed.name}" (${removed.id}) removed.`));
}
export type TemplatesShowOptions = {
id: string;
channel?: string;
expand?: boolean;
vars?: string;
};
export async function templatesShowCommand(
opts: TemplatesShowOptions,
runtime: RuntimeEnv,
): Promise<void> {
const templates = loadTemplates();
const template = findTemplate(templates, opts.id);
if (!template) {
runtime.error(`Template "${opts.id}" not found.`);
return;
}
let content = resolveTemplateContent(template, opts.channel);
if (opts.expand) {
const vars = opts.vars ? parseVars(opts.vars) : {};
content = expandTemplateVariables(content, vars);
}
const rich = isRich();
runtime.log(rich ? theme.heading(template.name) : template.name);
runtime.log(rich ? theme.muted(`ID: ${template.id}`) : `ID: ${template.id}`);
if (template.agents?.length) {
runtime.log(rich ? theme.muted(`Agents: ${template.agents.join(", ")}`) : `Agents: ${template.agents.join(", ")}`);
}
if (template.channels) {
runtime.log(
rich
? theme.muted(`Channel overrides: ${Object.keys(template.channels).join(", ")}`)
: `Channel overrides: ${Object.keys(template.channels).join(", ")}`,
);
}
runtime.log("");
runtime.log(content);
}
export type TemplatesUpdateOptions = {
id: string;
name?: string;
content?: string;
agents?: string;
};
export async function templatesUpdateCommand(
opts: TemplatesUpdateOptions,
runtime: RuntimeEnv,
): Promise<void> {
const templates = loadTemplates();
const template = templates.find((t) => t.id === opts.id);
if (!template) {
runtime.error(`Template "${opts.id}" not found.`);
return;
}
if (opts.name) template.name = opts.name;
if (opts.content) {
template.content = opts.content;
const varMatches = opts.content.match(/\{(\w+)\}/g) ?? [];
template.variables = [...new Set(varMatches.map((m) => m.slice(1, -1)))];
}
if (opts.agents) {
template.agents = opts.agents.split(",").map((a) => a.trim());
}
template.updatedAt = new Date().toISOString();
await saveTemplates(templates);
runtime.log(info(`Template "${template.name}" (${template.id}) updated.`));
}
// ── Helpers ──
function parseChannelOverrides(raw: string): Record<string, string> {
// Format: "telegram:Hello from TG,discord:Hello from Discord"
const result: Record<string, string> = {};
for (const pair of raw.split(",")) {
const colonIdx = pair.indexOf(":");
if (colonIdx > 0) {
const channel = pair.slice(0, colonIdx).trim();
const content = pair.slice(colonIdx + 1).trim();
if (channel && content) result[channel] = content;
}
}
return Object.keys(result).length > 0 ? result : {};
}
function parseVars(raw: string): Record<string, string> {
// Format: "key1=value1,key2=value2"
const result: Record<string, string> = {};
for (const pair of raw.split(",")) {
const eqIdx = pair.indexOf("=");
if (eqIdx > 0) {
const key = pair.slice(0, eqIdx).trim();
const value = pair.slice(eqIdx + 1).trim();
if (key) result[key] = value;
}
}
return result;
}

View File

@ -0,0 +1,209 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NotificationManager } from "./manager.js";
import type { Notification, NotificationListener } from "./types.js";
describe("NotificationManager", () => {
let tmpDir: string;
let manager: NotificationManager;
beforeEach(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-notif-test-"));
manager = new NotificationManager(tmpDir);
});
afterEach(async () => {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
});
it("creates a notification", async () => {
const n = await manager.notify({
title: "New message",
body: "Hello from Telegram",
channel: "telegram",
});
expect(n).not.toBeNull();
expect(n?.title).toBe("New message");
expect(n?.read).toBe(false);
expect(n?.category).toBe("message");
expect(n?.priority).toBe("normal");
});
it("stores and retrieves notifications", async () => {
await manager.notify({ title: "Test 1", body: "Body 1" });
await manager.notify({ title: "Test 2", body: "Body 2" });
const all = manager.getAll();
expect(all).toHaveLength(2);
});
it("filters unread notifications", async () => {
const n1 = await manager.notify({ title: "Test 1", body: "Body 1" });
await manager.notify({ title: "Test 2", body: "Body 2" });
await manager.markRead(n1!.id);
const unread = manager.getUnread();
expect(unread).toHaveLength(1);
expect(unread[0].title).toBe("Test 2");
});
it("marks all as read", async () => {
await manager.notify({ title: "Test 1", body: "Body 1" });
await manager.notify({ title: "Test 2", body: "Body 2" });
const count = await manager.markAllRead();
expect(count).toBe(2);
expect(manager.getUnread()).toHaveLength(0);
});
it("clears all notifications", async () => {
await manager.notify({ title: "Test 1", body: "Body 1" });
await manager.notify({ title: "Test 2", body: "Body 2" });
const count = await manager.clear();
expect(count).toBe(2);
expect(manager.getAll()).toHaveLength(0);
});
it("respects priority threshold", async () => {
await manager.updatePreferences({ minPriority: "high" });
const low = await manager.notify({
title: "Low",
body: "Low priority",
priority: "low",
});
const high = await manager.notify({
title: "High",
body: "High priority",
priority: "high",
});
expect(low).toBeNull(); // suppressed
expect(high).not.toBeNull();
});
it("suppresses when disabled", async () => {
await manager.updatePreferences({ enabled: false });
const n = await manager.notify({ title: "Test", body: "Body" });
expect(n).toBeNull();
});
it("dispatches to listeners", async () => {
const received: Notification[] = [];
const listener: NotificationListener = {
onNotification: (n) => {
received.push(n);
},
};
manager.addListener(listener);
await manager.notify({ title: "Test", body: "Body" });
expect(received).toHaveLength(1);
expect(received[0].title).toBe("Test");
});
it("removes listeners", async () => {
const received: Notification[] = [];
const listener: NotificationListener = {
onNotification: (n) => {
received.push(n);
},
};
manager.addListener(listener);
manager.removeListener(listener);
await manager.notify({ title: "Test", body: "Body" });
expect(received).toHaveLength(0);
});
it("groups notifications", async () => {
await manager.notify({ title: "T1", body: "B1", channel: "telegram" });
await manager.notify({ title: "T2", body: "B2", channel: "telegram" });
await manager.notify({ title: "T3", body: "B3", channel: "discord" });
const groups = manager.getGrouped();
expect(groups.get("telegram")).toHaveLength(2);
expect(groups.get("discord")).toHaveLength(1);
});
it("filters by category", async () => {
await manager.notify({ title: "Msg", body: "Message", category: "message" });
await manager.notify({ title: "Err", body: "Error", category: "error" });
const errors = manager.getAll({ category: "error" });
expect(errors).toHaveLength(1);
expect(errors[0].title).toBe("Err");
});
it("persists and reloads from disk", async () => {
await manager.notify({ title: "Persisted", body: "Saved to disk" });
// Create a new manager pointing at the same dir
const manager2 = new NotificationManager(tmpDir);
const all = manager2.getAll();
expect(all).toHaveLength(1);
expect(all[0].title).toBe("Persisted");
});
it("persists and reloads preferences", async () => {
await manager.updatePreferences({ minPriority: "high", groupBy: "agent" });
const manager2 = new NotificationManager(tmpDir);
const prefs = manager2.getPreferences();
expect(prefs.minPriority).toBe("high");
expect(prefs.groupBy).toBe("agent");
});
it("handles quiet hours (overnight range)", async () => {
// Set quiet hours that should be active now
const now = new Date();
const startMinutes = now.getHours() * 60 + now.getMinutes() - 30;
const endMinutes = startMinutes + 60;
const formatTime = (mins: number) => {
const h = Math.floor(((mins % 1440) + 1440) % 1440 / 60);
const m = ((mins % 60) + 60) % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
await manager.updatePreferences({
quietHours: {
enabled: true,
start: formatTime(startMinutes),
end: formatTime(endMinutes),
},
});
// Normal priority should be suppressed during quiet hours
const normal = await manager.notify({
title: "Normal",
body: "Normal priority",
priority: "normal",
});
expect(normal).toBeNull();
// Urgent should bypass quiet hours
const urgent = await manager.notify({
title: "Urgent",
body: "Urgent priority",
priority: "urgent",
});
expect(urgent).not.toBeNull();
});
it("includes actions in notifications", async () => {
const n = await manager.notify({
title: "Tool approval",
body: "Agent wants to run: deploy",
category: "tool-approval",
actions: [
{ id: "approve", label: "Approve", type: "approve" },
{ id: "deny", label: "Deny", type: "dismiss" },
],
});
expect(n?.actions).toHaveLength(2);
expect(n?.actions?.[0].type).toBe("approve");
});
});

View File

@ -0,0 +1,303 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import {
DEFAULT_NOTIFICATION_PREFERENCES,
PRIORITY_ORDER,
type Notification,
type NotificationCategory,
type NotificationListener,
type NotificationPreferences,
type NotificationPriority,
} from "./types.js";
const NOTIFICATIONS_FILENAME = "notifications.json";
const PREFS_FILENAME = "notification-prefs.json";
const MAX_STORED_NOTIFICATIONS = 500;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export class NotificationManager {
private listeners: NotificationListener[] = [];
private notifications: Notification[] = [];
private preferences: NotificationPreferences;
private stateDir: string;
private loaded = false;
constructor(stateDir?: string) {
this.stateDir = stateDir ?? resolveStateDir();
this.preferences = { ...DEFAULT_NOTIFICATION_PREFERENCES };
}
/** Register a listener that receives notifications (e.g. push provider, websocket). */
addListener(listener: NotificationListener): void {
this.listeners.push(listener);
}
removeListener(listener: NotificationListener): void {
this.listeners = this.listeners.filter((l) => l !== listener);
}
/** Load stored notifications and preferences from disk. */
load(): void {
if (this.loaded) return;
this.loaded = true;
// Load preferences
const prefsPath = path.join(this.stateDir, PREFS_FILENAME);
try {
const raw = fs.readFileSync(prefsPath, "utf-8");
const parsed = JSON.parse(raw) as NotificationPreferences;
this.preferences = { ...DEFAULT_NOTIFICATION_PREFERENCES, ...parsed };
} catch {
// use defaults
}
// Load stored notifications
const notifPath = path.join(this.stateDir, NOTIFICATIONS_FILENAME);
try {
const raw = fs.readFileSync(notifPath, "utf-8");
const parsed = JSON.parse(raw) as Notification[];
if (Array.isArray(parsed)) {
this.notifications = parsed;
}
} catch {
// no stored notifications
}
// Prune old notifications
this.pruneOldNotifications();
}
/** Create and dispatch a notification. */
async notify(params: {
title: string;
body: string;
priority?: NotificationPriority;
category?: NotificationCategory;
channel?: string;
agentId?: string;
sessionKey?: string;
actions?: Notification["actions"];
groupKey?: string;
}): Promise<Notification | null> {
this.load();
const priority = params.priority ?? "normal";
const category = params.category ?? "message";
// Check if notification should be suppressed
if (!this.shouldDeliver(priority, category)) {
return null;
}
const notification: Notification = {
id: crypto.randomUUID(),
title: params.title,
body: params.body,
priority,
category,
channel: params.channel,
agentId: params.agentId,
sessionKey: params.sessionKey,
createdAt: Date.now(),
read: false,
actions: params.actions,
groupKey: params.groupKey ?? this.resolveGroupKey(params),
};
this.notifications.push(notification);
this.pruneOldNotifications();
await this.save();
// Dispatch to listeners
for (const listener of this.listeners) {
try {
await listener.onNotification(notification);
} catch {
// don't let listener errors break the flow
}
}
return notification;
}
/** Mark a notification as read. */
async markRead(id: string): Promise<boolean> {
this.load();
const notification = this.notifications.find((n) => n.id === id);
if (!notification) return false;
notification.read = true;
await this.save();
return true;
}
/** Mark all notifications as read. */
async markAllRead(): Promise<number> {
this.load();
let count = 0;
for (const n of this.notifications) {
if (!n.read) {
n.read = true;
count++;
}
}
if (count > 0) await this.save();
return count;
}
/** Get unread notifications. */
getUnread(): Notification[] {
this.load();
return this.notifications.filter((n) => !n.read);
}
/** Get all notifications (optionally filtered). */
getAll(opts?: {
category?: NotificationCategory;
channel?: string;
agentId?: string;
limit?: number;
}): Notification[] {
this.load();
let result = [...this.notifications];
if (opts?.category) {
result = result.filter((n) => n.category === opts.category);
}
if (opts?.channel) {
result = result.filter((n) => n.channel === opts.channel);
}
if (opts?.agentId) {
result = result.filter((n) => n.agentId === opts.agentId);
}
// Sort by creation time, newest first
result.sort((a, b) => b.createdAt - a.createdAt);
if (opts?.limit) {
result = result.slice(0, opts.limit);
}
return result;
}
/** Get grouped notifications. */
getGrouped(): Map<string, Notification[]> {
this.load();
const groups = new Map<string, Notification[]>();
for (const n of this.getUnread()) {
const key = n.groupKey ?? "ungrouped";
const group = groups.get(key) ?? [];
group.push(n);
groups.set(key, group);
}
return groups;
}
/** Get current preferences. */
getPreferences(): NotificationPreferences {
this.load();
return { ...this.preferences };
}
/** Update preferences. */
async updatePreferences(patch: Partial<NotificationPreferences>): Promise<void> {
this.load();
this.preferences = { ...this.preferences, ...patch };
const prefsPath = path.join(this.stateDir, PREFS_FILENAME);
await fs.promises.mkdir(path.dirname(prefsPath), { recursive: true });
await fs.promises.writeFile(prefsPath, JSON.stringify(this.preferences, null, 2), "utf-8");
}
/** Clear all notifications. */
async clear(): Promise<number> {
this.load();
const count = this.notifications.length;
this.notifications = [];
await this.save();
return count;
}
// ── Private ──
private shouldDeliver(priority: NotificationPriority, category: NotificationCategory): boolean {
if (!this.preferences.enabled) return false;
// Check priority threshold
if (PRIORITY_ORDER[priority] < PRIORITY_ORDER[this.preferences.minPriority]) {
return false;
}
// Check category filter
if (this.preferences.categories && this.preferences.categories.length > 0) {
if (!this.preferences.categories.includes(category)) return false;
}
// Check quiet hours (urgent bypasses)
if (priority !== "urgent" && this.isInQuietHours()) {
return false;
}
return true;
}
private isInQuietHours(): boolean {
const qh = this.preferences.quietHours;
if (!qh?.enabled) return false;
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const [startH, startM] = qh.start.split(":").map(Number);
const [endH, endM] = qh.end.split(":").map(Number);
const startMinutes = startH * 60 + startM;
const endMinutes = endH * 60 + endM;
if (startMinutes <= endMinutes) {
// Same day range (e.g. 09:00 - 17:00)
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
}
// Overnight range (e.g. 22:00 - 08:00)
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
}
private resolveGroupKey(params: {
channel?: string;
agentId?: string;
category?: string;
}): string {
const groupBy = this.preferences.groupBy ?? "channel";
if (groupBy === "channel") return params.channel ?? "unknown";
if (groupBy === "agent") return params.agentId ?? "unknown";
return params.category ?? "message";
}
private pruneOldNotifications(): void {
const cutoff = Date.now() - MAX_AGE_MS;
this.notifications = this.notifications
.filter((n) => n.createdAt > cutoff)
.slice(-MAX_STORED_NOTIFICATIONS);
}
private async save(): Promise<void> {
const notifPath = path.join(this.stateDir, NOTIFICATIONS_FILENAME);
await fs.promises.mkdir(path.dirname(notifPath), { recursive: true });
await fs.promises.writeFile(notifPath, JSON.stringify(this.notifications, null, 2), "utf-8");
}
}
// Singleton for the default state dir
let defaultManager: NotificationManager | null = null;
export function getNotificationManager(): NotificationManager {
if (!defaultManager) {
defaultManager = new NotificationManager();
}
return defaultManager;
}
/** Reset the singleton (for tests). */
export function resetNotificationManager(): void {
defaultManager = null;
}

View File

@ -0,0 +1,78 @@
export type NotificationPriority = "low" | "normal" | "high" | "urgent";
export type NotificationCategory =
| "message"
| "agent-complete"
| "tool-approval"
| "error"
| "system";
export type Notification = {
id: string;
/** Notification title (short summary). */
title: string;
/** Notification body (detail text). */
body: string;
/** Priority for routing (urgent bypasses DND). */
priority: NotificationPriority;
/** Category for grouping. */
category: NotificationCategory;
/** Source channel that triggered this notification. */
channel?: string;
/** Agent id that generated this notification. */
agentId?: string;
/** Session key for context. */
sessionKey?: string;
/** Timestamp when notification was created. */
createdAt: number;
/** Whether the notification has been read/dismissed. */
read: boolean;
/** Actionable quick-reply options. */
actions?: NotificationAction[];
/** Grouping key for smart notification batching. */
groupKey?: string;
};
export type NotificationAction = {
id: string;
label: string;
/** Action type: reply sends text back, approve accepts tool execution, dismiss clears. */
type: "reply" | "approve" | "dismiss" | "snooze";
/** Pre-filled reply text (for reply actions). */
payload?: string;
};
export type NotificationPreferences = {
/** Global enable/disable for notifications. */
enabled: boolean;
/** Priority threshold: only deliver notifications at or above this level. */
minPriority: NotificationPriority;
/** Categories to deliver (empty = all). */
categories?: NotificationCategory[];
/** Quiet hours: suppress non-urgent notifications. */
quietHours?: {
enabled: boolean;
start: string; // "22:00"
end: string; // "08:00"
timezone?: string;
};
/** Group notifications by this key (default: "channel"). */
groupBy?: "channel" | "agent" | "category";
};
export type NotificationListener = {
onNotification: (notification: Notification) => void | Promise<void>;
};
export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
enabled: true,
minPriority: "normal",
groupBy: "channel",
};
export const PRIORITY_ORDER: Record<NotificationPriority, number> = {
low: 0,
normal: 1,
high: 2,
urgent: 3,
};