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 { healthCommand } from "../../commands/health.js";
|
||||
import { sessionsCommand } from "../../commands/sessions.js";
|
||||
import { sessionsSearchCommand } from "../../commands/sessions-search.js";
|
||||
import { statusCommand } from "../../commands/status.js";
|
||||
import { defaultRuntime } from "../../runtime.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 { registerMemoryCli, runMemoryStatus } from "../memory-cli.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 { registerMaintenanceCommands } from "./register.maintenance.js";
|
||||
import { registerMessageCommands } from "./register.message.js";
|
||||
@ -17,6 +20,7 @@ import { registerOnboardCommand } from "./register.onboard.js";
|
||||
import { registerSetupCommand } from "./register.setup.js";
|
||||
import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js";
|
||||
import { registerSubCliCommands } from "./register.subclis.js";
|
||||
import { registerTemplatesCommands } from "./register.templates.js";
|
||||
import type { ProgramContext } from "./context.js";
|
||||
|
||||
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 = {
|
||||
match: (path) => path[0] === "memory" && path[1] === "status",
|
||||
run: async (argv) => {
|
||||
@ -146,12 +183,24 @@ export const commandRegistry: CommandRegistration[] = [
|
||||
{
|
||||
id: "status-health-sessions",
|
||||
register: ({ program }) => registerStatusHealthSessionsCommands(program),
|
||||
routes: [routeHealth, routeStatus, routeSessions],
|
||||
routes: [routeHealth, routeStatus, routeSessions, routeSearch],
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
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(
|
||||
|
||||
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 { healthCommand } from "../../commands/health.js";
|
||||
import { sessionsCommand } from "../../commands/sessions.js";
|
||||
import {
|
||||
sessionsBookmarkCommand,
|
||||
sessionsSearchCommand,
|
||||
} from "../../commands/sessions-search.js";
|
||||
import { statusCommand } from "../../commands/status.js";
|
||||
import { setVerbose } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
@ -143,4 +147,62 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
||||
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