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:
parent
9d75e6dc9c
commit
cc6e18ff2b
@ -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(
|
||||||
|
|||||||
104
src/cli/program/register.backup.ts
Normal file
104
src/cli/program/register.backup.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
114
src/cli/program/register.notifications.ts
Normal file
114
src/cli/program/register.notifications.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
148
src/cli/program/register.templates.ts
Normal file
148
src/cli/program/register.templates.ts
Normal 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
201
src/commands/backup.test.ts
Normal 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
477
src/commands/backup.ts
Normal 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]}`;
|
||||||
|
}
|
||||||
180
src/commands/notifications.ts
Normal file
180
src/commands/notifications.ts
Normal 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(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/commands/sessions-search.test.ts
Normal file
177
src/commands/sessions-search.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
324
src/commands/sessions-search.ts
Normal file
324
src/commands/sessions-search.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/commands/templates.test.ts
Normal file
225
src/commands/templates.test.ts
Normal 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
323
src/commands/templates.ts
Normal 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;
|
||||||
|
}
|
||||||
209
src/notifications/manager.test.ts
Normal file
209
src/notifications/manager.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
303
src/notifications/manager.ts
Normal file
303
src/notifications/manager.ts
Normal 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;
|
||||||
|
}
|
||||||
78
src/notifications/types.ts
Normal file
78
src/notifications/types.ts
Normal 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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user