@holon-run/agentinbox 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -299,6 +299,22 @@ that database next to the DB path (for example,
299
299
  `~/.agentinbox/agentinbox.sqlite.pre-v1.<timestamp>.bak`) and starts with a
300
300
  fresh v1 database; pre-v1 local data is not imported.
301
301
 
302
+ ## Database Backups
303
+
304
+ Startup backups are event-driven, not unconditional:
305
+
306
+ - before pending schema migrations run, a full backup is written to
307
+ `<db>.pre-migrate-v<N>.bak` (bounded by `AGENTINBOX_MIGRATION_BACKUP_KEEP`,
308
+ default 5; `0` keeps all)
309
+ - `agentinbox backup [--home DIR] [--state PATH]` writes a manual snapshot to
310
+ `<db>.bak` on demand, for scheduled or pre-upgrade snapshots
311
+ - regular opens verify the database with `PRAGMA quick_check` and escalate to
312
+ a full `integrity_check` only when the quick pass fails; recovery considers
313
+ manual and pre-migration backups, most recent first
314
+
315
+ Setting `AGENTINBOX_STARTUP_BACKUP=1` restores the legacy behavior of backing
316
+ up (and fully checking) the database on every open.
317
+
302
318
  ## License
303
319
 
304
320
  Apache-2.0
@@ -5,6 +5,7 @@ const paths_1 = require("./paths");
5
5
  const feishu_1 = require("./sources/feishu");
6
6
  const github_1 = require("./sources/github");
7
7
  const telegram_1 = require("./sources/telegram");
8
+ const email_1 = require("./sources/email");
8
9
  const remote_1 = require("./sources/remote");
9
10
  const remote_modules_1 = require("./sources/remote_modules");
10
11
  const source_resolution_1 = require("./source_resolution");
@@ -42,6 +43,7 @@ class AdapterRegistry {
42
43
  feishuDelivery;
43
44
  githubDelivery = new github_1.GithubDeliveryAdapter();
44
45
  telegramDelivery;
46
+ emailDelivery;
45
47
  homeDir;
46
48
  remoteModuleRegistry;
47
49
  constructor(store, appendSourceEvent, options) {
@@ -51,6 +53,7 @@ class AdapterRegistry {
51
53
  });
52
54
  this.feishuDelivery = new feishu_1.FeishuDeliveryAdapter(options?.feishuClient);
53
55
  this.telegramDelivery = new telegram_1.TelegramDeliveryAdapter(options?.telegramClient);
56
+ this.emailDelivery = new email_1.EmailDeliveryAdapter(options?.emailClient);
54
57
  this.remoteSource = new remote_1.RemoteSourceRuntime(store, appendSourceEvent, {
55
58
  homeDir: this.homeDir,
56
59
  client: options?.remoteSourceClient,
@@ -76,6 +79,9 @@ class AdapterRegistry {
76
79
  if (type === "telegram_bot") {
77
80
  return this.remoteSource;
78
81
  }
82
+ if (type === "email_mailbox") {
83
+ return this.remoteSource;
84
+ }
79
85
  return this.localEventSource;
80
86
  }
81
87
  deliveryAdapterFor(provider) {
@@ -88,6 +94,9 @@ class AdapterRegistry {
88
94
  if (provider === "telegram") {
89
95
  return this.telegramDelivery;
90
96
  }
97
+ if (provider === "email") {
98
+ return this.emailDelivery;
99
+ }
91
100
  return this.defaultDelivery;
92
101
  }
93
102
  async start() {
@@ -251,6 +260,9 @@ class AdapterRegistry {
251
260
  if (handle.provider === "telegram") {
252
261
  return this.remoteModuleRegistry.resolve(syntheticBuiltinSource("telegram_bot"), this.homeDir);
253
262
  }
263
+ if (handle.provider === "email") {
264
+ return this.remoteModuleRegistry.resolve(syntheticBuiltinSource("email_mailbox"), this.homeDir);
265
+ }
254
266
  return null;
255
267
  }
256
268
  }
@@ -262,6 +274,7 @@ function isExplicitFollowPreviewRef(value) {
262
274
  value === "github_repo_ci" ||
263
275
  value === "feishu_bot" ||
264
276
  value === "telegram_bot" ||
277
+ value === "email_mailbox" ||
265
278
  value.startsWith("remote:"));
266
279
  }
267
280
  function hasRemoteSourceModulePath(config) {
package/dist/src/cli.js CHANGED
@@ -60,6 +60,14 @@ async function main() {
60
60
  await runDaemon(normalized.slice(1));
61
61
  return;
62
62
  }
63
+ if (command === "backup") {
64
+ if (hasHelpFlag(normalized.slice(1))) {
65
+ printHelp(["backup"]);
66
+ return;
67
+ }
68
+ await runBackup(normalized.slice(1));
69
+ return;
70
+ }
63
71
  if (hasHelpFlag(normalized.slice(1))) {
64
72
  printHelp([command]);
65
73
  return;
@@ -971,6 +979,7 @@ const COMMAND_SHELL_SPECS = [
971
979
  { name: "subscription", group: true, summary: "Manage source subscriptions." },
972
980
  { name: "inbox", group: true, summary: "Read and manage agent inboxes." },
973
981
  { name: "gc", group: false, summary: "Run garbage collection." },
982
+ { name: "backup", group: false, summary: "Write a local database backup snapshot." },
974
983
  { name: "deliver", group: true, summary: "Send outbound delivery actions." },
975
984
  { name: "status", group: false, summary: "Show daemon status." },
976
985
  { name: "version", group: false, summary: "Show CLI version." },
@@ -1006,6 +1015,25 @@ function parseCommanderShell(args) {
1006
1015
  isBareGroup: Boolean(COMMAND_SHELL_SPECS.find((item) => item.name === spec?.name())?.group && args.length === 1),
1007
1016
  };
1008
1017
  }
1018
+ async function runBackup(args) {
1019
+ const serveConfig = (0, paths_1.resolveServeConfig)({
1020
+ env: process.env,
1021
+ homeDirOverride: takeFlagValue(args, "--home"),
1022
+ statePathOverride: takeFlagValue(args, "--state"),
1023
+ });
1024
+ const store = await store_1.AgentInboxStore.open(serveConfig.dbPath);
1025
+ try {
1026
+ const backupPath = await store.backupDatabase();
1027
+ console.log((0, util_1.jsonResponse)({
1028
+ ok: true,
1029
+ dbPath: serveConfig.dbPath,
1030
+ backupPath,
1031
+ }));
1032
+ }
1033
+ finally {
1034
+ store.close();
1035
+ }
1036
+ }
1009
1037
  async function runServe(args) {
1010
1038
  const port = parseOptionalNumber(takeFlagValue(args, "--port"));
1011
1039
  const homeOverride = takeFlagValue(args, "--home");
@@ -1677,6 +1705,14 @@ Usage:
1677
1705
  agentinbox daemon start [--home ~/.agentinbox] [--socket ~/.agentinbox/agentinbox.sock] [--log-level error|warn|info|debug|trace]
1678
1706
  agentinbox daemon stop [--home ~/.agentinbox] [--socket ~/.agentinbox/agentinbox.sock]
1679
1707
  agentinbox daemon status [--home ~/.agentinbox] [--socket ~/.agentinbox/agentinbox.sock]
1708
+ `,
1709
+ backup: `agentinbox backup
1710
+
1711
+ Usage:
1712
+ agentinbox backup [--home ~/.agentinbox] [--state ~/.agentinbox/agentinbox.sqlite]
1713
+
1714
+ Writes a verified snapshot to <db>.bak (replaces the previous manual backup).
1715
+ Pre-migration backups (<db>.pre-migrate-v<N>.bak) are created automatically.
1680
1716
  `,
1681
1717
  host: `agentinbox host
1682
1718
 
@@ -3313,7 +3313,8 @@ function sourceTypeForPreviewRef(sourceRef) {
3313
3313
  sourceRef === "github_repo" ||
3314
3314
  sourceRef === "github_repo_ci" ||
3315
3315
  sourceRef === "feishu_bot" ||
3316
- sourceRef === "telegram_bot") {
3316
+ sourceRef === "telegram_bot" ||
3317
+ sourceRef === "email_mailbox") {
3317
3318
  return sourceRef;
3318
3319
  }
3319
3320
  if (sourceRef.startsWith("remote:")) {
@@ -3615,6 +3616,9 @@ function providerForSourceType(sourceType) {
3615
3616
  if (sourceType === "telegram_bot") {
3616
3617
  return "telegram";
3617
3618
  }
3619
+ if (sourceType === "email_mailbox") {
3620
+ return "email";
3621
+ }
3618
3622
  return null;
3619
3623
  }
3620
3624
  function authHeaders(auth) {
@@ -4209,6 +4213,8 @@ function listHostStreamKinds(hostType) {
4209
4213
  return ["message_events"];
4210
4214
  case "telegram":
4211
4215
  return ["message_updates"];
4216
+ case "email":
4217
+ return ["message_events"];
4212
4218
  case "local_event":
4213
4219
  return ["events"];
4214
4220
  case "remote_source":
@@ -4218,6 +4224,7 @@ function listHostStreamKinds(hostType) {
4218
4224
  const KNOWN_DELIVERY_SURFACES = {
4219
4225
  feishu: ["message_reply", "chat_message"],
4220
4226
  github: ["issue_comment", "pull_request_comment", "review_comment"],
4227
+ email: ["message_reply", "message_send"],
4221
4228
  };
4222
4229
  function unsupportedDeliverySurfaceMessage(handle) {
4223
4230
  const supported = KNOWN_DELIVERY_SURFACES[handle.provider];
@@ -4298,6 +4305,12 @@ function getHostConfigFields(hostType) {
4298
4305
  { name: "tokenEnv", type: "string", description: "Environment variable containing the Telegram Bot API token.", required: false },
4299
4306
  { name: "botUsername", type: "string", description: "Optional bot username used for shared host identity.", required: false },
4300
4307
  ];
4308
+ case "email":
4309
+ return [
4310
+ { name: "uxcAuth", type: "string", description: "UXC auth profile holding mailbox credentials.", required: false },
4311
+ { name: "smtpEndpoint", type: "string", description: "Default smtp:// endpoint for outbound delivery.", required: false },
4312
+ { name: "fromAddress", type: "string", description: "Default outbound From address.", required: false },
4313
+ ];
4301
4314
  case "local_event":
4302
4315
  return [];
4303
4316
  case "remote_source":
@@ -4317,6 +4330,9 @@ function sourceTypeForStreamRegistration(hostType, streamKind) {
4317
4330
  if (hostType === "telegram") {
4318
4331
  return "telegram_bot";
4319
4332
  }
4333
+ if (hostType === "email") {
4334
+ return "email_mailbox";
4335
+ }
4320
4336
  if (hostType === "local_event") {
4321
4337
  return "local_event";
4322
4338
  }
@@ -60,6 +60,22 @@ function resolveSourceRegistration(input) {
60
60
  sourceType: input.sourceType,
61
61
  };
62
62
  }
63
+ if (input.sourceType === "email_mailbox") {
64
+ const provider = stringOrDefault(config.provider, "imap");
65
+ return {
66
+ hostType: "email",
67
+ hostKey: `email:${provider}:${stringOrDefault(config.account, stringOrDefault(config.uxcAuth, input.sourceKey))}`,
68
+ hostConfig: {
69
+ ...(valueOrUndefined(config.uxcAuth) ? { uxcAuth: config.uxcAuth } : {}),
70
+ ...(valueOrUndefined(config.smtpEndpoint) ? { smtpEndpoint: config.smtpEndpoint } : {}),
71
+ ...(valueOrUndefined(config.fromAddress) ? { fromAddress: config.fromAddress } : {}),
72
+ },
73
+ streamKind: "message_events",
74
+ streamKey: input.sourceKey,
75
+ streamConfig: config,
76
+ sourceType: input.sourceType,
77
+ };
78
+ }
63
79
  if (input.sourceType === "local_event") {
64
80
  return {
65
81
  hostType: "local_event",
@@ -189,6 +189,57 @@ const SOURCE_SCHEMAS = {
189
189
  { name: "endpoint", type: "string", required: false, description: "Optional Telegram Bot API endpoint override." },
190
190
  ],
191
191
  },
192
+ email_mailbox: {
193
+ sourceType: "email_mailbox",
194
+ metadataFields: [
195
+ { name: "provider", type: "string", description: "Email provider: imap, gmail, graph, or jmap." },
196
+ { name: "account", type: "string", description: "Mailbox account alias." },
197
+ { name: "mailbox", type: "string", description: "Mailbox name such as INBOX." },
198
+ { name: "messageId", type: "string|null", description: "RFC Message-ID header when present." },
199
+ { name: "providerMessageId", type: "string|null", description: "Provider-native message id (IMAP UID or provider message id)." },
200
+ { name: "threadId", type: "string|null", description: "Thread reference derived from References/In-Reply-To headers." },
201
+ { name: "from", type: "string|null", description: "Normalized sender address." },
202
+ { name: "fromName", type: "string|null", description: "Sender display name when present." },
203
+ { name: "to", type: "object[]", description: "Normalized recipients with address and optional name." },
204
+ { name: "cc", type: "object[]", description: "Normalized cc recipients with address and optional name." },
205
+ { name: "subject", type: "string|null", description: "Message subject." },
206
+ { name: "textPreview", type: "string|null", description: "Short body snippet." },
207
+ { name: "date", type: "string|null", description: "Message date header value." },
208
+ { name: "hasAttachments", type: "boolean", description: "Whether the message carries attachments." },
209
+ { name: "attachmentCount", type: "number|null", description: "Attachment count; null when the provider did not expand attachment metadata." },
210
+ { name: "attachments", type: "object[]", description: "Attachment metadata entries with opaque retrieval handles; binary content is never embedded." },
211
+ ],
212
+ payloadExamples: [
213
+ {
214
+ type: "email_event",
215
+ version: "v1",
216
+ provider: "imap",
217
+ account: "user@example.com",
218
+ mailbox: "INBOX",
219
+ event_kind: "message_received",
220
+ message: {
221
+ uid: "42",
222
+ message_id: "<msg@example.com>",
223
+ from: "sender@example.com",
224
+ to: [{ raw: "user@example.com" }],
225
+ subject: "Quarterly report",
226
+ snippet: "Please review...",
227
+ },
228
+ },
229
+ ],
230
+ eventVariantExamples: ["email.message.received"],
231
+ configFields: [
232
+ { name: "provider", type: "string", required: true, description: "Email provider: imap, gmail, graph, or jmap." },
233
+ { name: "uxcAuth", type: "string", required: true, description: "UXC auth profile name holding mailbox credentials; credentials never inline." },
234
+ { name: "endpoint", type: "string", required: false, description: "imap:// or imaps:// endpoint for imap; provider API endpoint for gmail/graph/jmap (jmap requires it)." },
235
+ { name: "account", type: "string", required: false, description: "Account alias; defaults to the auth profile username." },
236
+ { name: "mailbox", type: "string", required: false, description: "Mailbox to watch; defaults to INBOX." },
237
+ { name: "pollIntervalSecs", type: "number", required: false, description: "Provider poll interval in seconds (min 15, default 60)." },
238
+ { name: "smtpEndpoint", type: "string", required: false, description: "Default smtp:// endpoint for outbound reply/send delivery." },
239
+ { name: "fromAddress", type: "string", required: false, description: "Default outbound From address for delivery." },
240
+ { name: "addressAllowlist", type: "string[]", required: false, description: "Optional from/to address allowlist applied before inbox routing." },
241
+ ],
242
+ },
192
243
  };
193
244
  function getSourceSchema(sourceType) {
194
245
  const schema = SOURCE_SCHEMAS[sourceType];
@@ -0,0 +1,532 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EmailDeliveryAdapter = exports.RpcEmailUxcClient = exports.GRAPH_MESSAGES_ENDPOINT = exports.GMAIL_MESSAGES_ENDPOINT = exports.EMAIL_EVENT_VERSION = exports.EMAIL_EVENT_TYPE = exports.EMAIL_MAILBOX_DEFAULT_POLL_INTERVAL_SECS = exports.EMAIL_DEFAULT_MAILBOX = void 0;
4
+ exports.parseEmailMailboxSourceConfig = parseEmailMailboxSourceConfig;
5
+ exports.buildEmailMailboxSourceSpec = buildEmailMailboxSourceSpec;
6
+ exports.normalizeEmailMailboxEvent = normalizeEmailMailboxEvent;
7
+ exports.emailDeliveryOperationsForHandle = emailDeliveryOperationsForHandle;
8
+ exports.invokeEmailDeliveryOperation = invokeEmailDeliveryOperation;
9
+ const uxc_daemon_client_1 = require("@holon-run/uxc-daemon-client");
10
+ exports.EMAIL_DEFAULT_MAILBOX = "INBOX";
11
+ exports.EMAIL_MAILBOX_DEFAULT_POLL_INTERVAL_SECS = 60;
12
+ exports.EMAIL_EVENT_TYPE = "email_event";
13
+ exports.EMAIL_EVENT_VERSION = "v1";
14
+ exports.GMAIL_MESSAGES_ENDPOINT = "https://gmail.googleapis.com/gmail/v1/users/me/messages";
15
+ exports.GRAPH_MESSAGES_ENDPOINT = "https://graph.microsoft.com/v1.0/me/messages?$expand=attachments";
16
+ const EMAIL_MAILBOX_PROVIDERS = new Set(["imap", "gmail", "graph", "jmap"]);
17
+ const EMAIL_DEFAULT_ENDPOINTS = {
18
+ gmail: exports.GMAIL_MESSAGES_ENDPOINT,
19
+ graph: exports.GRAPH_MESSAGES_ENDPOINT,
20
+ };
21
+ function parseEmailMailboxSourceConfig(source) {
22
+ const config = source.config ?? {};
23
+ const providerRaw = asString(config.provider) ?? asString(config.emailProvider);
24
+ if (!providerRaw) {
25
+ throw new Error("email_mailbox requires config.provider (imap, gmail, graph, or jmap)");
26
+ }
27
+ const provider = providerRaw.trim().toLowerCase();
28
+ if (!EMAIL_MAILBOX_PROVIDERS.has(provider)) {
29
+ throw new Error(`email_mailbox config.provider must be one of imap, gmail, graph, jmap; got ${providerRaw}`);
30
+ }
31
+ const endpointRaw = asString(config.endpoint);
32
+ let endpoint;
33
+ if (provider === "imap") {
34
+ if (!endpointRaw) {
35
+ throw new Error("email_mailbox with provider=imap requires config.endpoint (imap:// or imaps://)");
36
+ }
37
+ if (!/^imaps?:\/\//i.test(endpointRaw)) {
38
+ throw new Error(`email_mailbox imap endpoint must start with imap:// or imaps://; got ${endpointRaw}`);
39
+ }
40
+ endpoint = endpointRaw;
41
+ }
42
+ else {
43
+ const fallback = EMAIL_DEFAULT_ENDPOINTS[provider];
44
+ if (endpointRaw) {
45
+ if (!/^https?:\/\//i.test(endpointRaw)) {
46
+ throw new Error(`email_mailbox ${provider} endpoint must start with http:// or https://; got ${endpointRaw}`);
47
+ }
48
+ endpoint = endpointRaw;
49
+ }
50
+ else if (fallback) {
51
+ endpoint = fallback;
52
+ }
53
+ else {
54
+ throw new Error(`email_mailbox with provider=${provider} requires config.endpoint (${provider} API URL)`);
55
+ }
56
+ }
57
+ const uxcAuth = asString(config.uxcAuth) ?? asString(config.auth);
58
+ if (!uxcAuth || uxcAuth.trim().length === 0) {
59
+ throw new Error("email_mailbox requires config.uxcAuth (UXC auth profile name; credentials stay in UXC)");
60
+ }
61
+ const pollIntervalRaw = config.pollIntervalSecs;
62
+ let pollIntervalSecs = exports.EMAIL_MAILBOX_DEFAULT_POLL_INTERVAL_SECS;
63
+ if (pollIntervalRaw !== undefined && pollIntervalRaw !== null) {
64
+ const parsed = numberFromUnknown(pollIntervalRaw);
65
+ if (parsed === undefined || !Number.isInteger(parsed) || parsed < 15) {
66
+ throw new Error(`email_mailbox config.pollIntervalSecs must be an integer >= 15; got ${String(pollIntervalRaw)}`);
67
+ }
68
+ pollIntervalSecs = parsed;
69
+ }
70
+ const smtpEndpoint = asString(config.smtpEndpoint);
71
+ if (smtpEndpoint && !/^smtp:\/\/./i.test(smtpEndpoint)) {
72
+ throw new Error(`email_mailbox config.smtpEndpoint must use smtp:// (uxc SMTP surface); got ${smtpEndpoint}`);
73
+ }
74
+ const fromAddress = asString(config.fromAddress);
75
+ if (fromAddress && !fromAddress.includes("@")) {
76
+ throw new Error(`email_mailbox config.fromAddress must be an email address; got ${fromAddress}`);
77
+ }
78
+ const account = asString(config.account) ?? undefined;
79
+ if (account !== undefined && account.trim().length === 0) {
80
+ throw new Error("email_mailbox config.account must be a non-empty string when provided");
81
+ }
82
+ const mailbox = asString(config.mailbox) ?? exports.EMAIL_DEFAULT_MAILBOX;
83
+ if (mailbox.trim().length === 0) {
84
+ throw new Error("email_mailbox config.mailbox must be a non-empty string when provided");
85
+ }
86
+ const addressAllowlist = asStringArray(config.addressAllowlist ?? config.addressAllowList);
87
+ return {
88
+ provider: provider,
89
+ endpoint,
90
+ uxcAuth: uxcAuth.trim(),
91
+ account: account?.trim() || undefined,
92
+ mailbox,
93
+ pollIntervalSecs,
94
+ ...(smtpEndpoint ? { smtpEndpoint } : {}),
95
+ ...(fromAddress ? { fromAddress } : {}),
96
+ ...(addressAllowlist && addressAllowlist.length > 0 ? { addressAllowlist } : {}),
97
+ };
98
+ }
99
+ function buildEmailMailboxSourceSpec(config) {
100
+ const args = compactRecord({
101
+ mailbox: config.mailbox,
102
+ account: config.account,
103
+ });
104
+ if (config.provider === "imap") {
105
+ return {
106
+ endpoint: config.endpoint,
107
+ mode: "stream",
108
+ transport_hint: "email_imap_idle",
109
+ args,
110
+ options: {
111
+ auth: config.uxcAuth,
112
+ artifact_compaction: false,
113
+ },
114
+ };
115
+ }
116
+ return {
117
+ endpoint: config.endpoint,
118
+ mode: "poll",
119
+ transport_hint: "email_provider_poll",
120
+ args: {
121
+ provider: config.provider,
122
+ ...args,
123
+ },
124
+ poll_config: {
125
+ interval_secs: config.pollIntervalSecs,
126
+ extract_items_pointer: "/items",
127
+ checkpoint_strategy: {
128
+ type: "item_key",
129
+ item_key_pointer: "/message/uid",
130
+ },
131
+ },
132
+ options: {
133
+ auth: config.uxcAuth,
134
+ artifact_compaction: false,
135
+ },
136
+ };
137
+ }
138
+ function emailActorParts(value) {
139
+ if (typeof value === "string") {
140
+ const raw = value.trim();
141
+ if (raw.length === 0) {
142
+ return null;
143
+ }
144
+ const angled = raw.match(/<([^>]+)>\s*$/);
145
+ if (angled) {
146
+ const name = raw.slice(0, raw.lastIndexOf("<")).trim().replace(/^"|"$/g, "").trim();
147
+ return { address: angled[1].trim(), name: name.length > 0 ? name : null, raw };
148
+ }
149
+ return { address: raw, name: null, raw };
150
+ }
151
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
152
+ return null;
153
+ }
154
+ const record = value;
155
+ if (typeof record.raw === "string") {
156
+ return emailActorParts(record.raw);
157
+ }
158
+ const address = asString(record.address) ?? asString(record.email);
159
+ if (address) {
160
+ return { address, name: asString(record.name), raw: address };
161
+ }
162
+ return null;
163
+ }
164
+ function emailActorAddresses(value) {
165
+ if (!Array.isArray(value)) {
166
+ return [];
167
+ }
168
+ const addresses = [];
169
+ for (const item of value) {
170
+ const parts = emailActorParts(item);
171
+ if (parts?.address) {
172
+ addresses.push(parts.address);
173
+ }
174
+ }
175
+ return addresses;
176
+ }
177
+ function emailActorList(value) {
178
+ if (!Array.isArray(value)) {
179
+ return [];
180
+ }
181
+ return value
182
+ .map((item) => emailActorParts(item))
183
+ .filter((parts) => Boolean(parts?.address))
184
+ .map((parts) => ({
185
+ address: parts.address,
186
+ ...(parts.name ? { name: parts.name } : {}),
187
+ }));
188
+ }
189
+ function normalizeEmailMailboxEvent(source, config, raw) {
190
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
191
+ return null;
192
+ }
193
+ const payload = raw;
194
+ if (payload.type !== exports.EMAIL_EVENT_TYPE) {
195
+ return null;
196
+ }
197
+ const version = asString(payload.version);
198
+ if (version !== null && version !== exports.EMAIL_EVENT_VERSION) {
199
+ return null;
200
+ }
201
+ if (payload.event_kind !== "message_received") {
202
+ return null;
203
+ }
204
+ const message = asRecord(payload.message);
205
+ if (Object.keys(message).length === 0) {
206
+ return null;
207
+ }
208
+ const provider = asString(payload.provider) ?? config.provider;
209
+ const account = asString(payload.account) ?? config.account ?? config.uxcAuth;
210
+ const mailbox = asString(payload.mailbox) ?? config.mailbox;
211
+ const uid = stringFromUnknown(message.uid);
212
+ const messageId = asString(message.message_id) ?? uid;
213
+ if (!uid && !messageId) {
214
+ return null;
215
+ }
216
+ const fromParts = emailActorParts(message.from);
217
+ const toAddresses = emailActorAddresses(message.to);
218
+ if (config.addressAllowlist &&
219
+ config.addressAllowlist.length > 0 &&
220
+ !addressMatchesAllowlist(config.addressAllowlist, fromParts?.address ?? null, toAddresses)) {
221
+ return null;
222
+ }
223
+ const attachments = Array.isArray(message.attachments)
224
+ ? message.attachments.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
225
+ : [];
226
+ const attachmentCountRaw = message.attachment_count;
227
+ const attachmentCount = numberFromUnknown(attachmentCountRaw) ?? null;
228
+ const date = asString(message.date);
229
+ return {
230
+ sourceId: source.sourceId,
231
+ sourceNativeId: `email:${provider}:${account}:${mailbox}:${uid ?? messageId}`,
232
+ eventVariant: "email.message.received",
233
+ occurredAt: occurredAtFromEmailDate(date),
234
+ metadata: {
235
+ provider,
236
+ account,
237
+ mailbox,
238
+ messageId: messageId ?? null,
239
+ providerMessageId: uid ?? null,
240
+ threadId: asString(message.thread_id),
241
+ from: fromParts ? (fromParts.address ?? fromParts.raw) : null,
242
+ fromName: fromParts?.name ?? null,
243
+ to: emailActorList(message.to),
244
+ cc: emailActorList(message.cc),
245
+ subject: asString(message.subject),
246
+ textPreview: asString(message.snippet),
247
+ date,
248
+ hasAttachments: attachments.length > 0 || message.has_attachments === true,
249
+ attachmentCount,
250
+ attachments,
251
+ },
252
+ rawPayload: payload,
253
+ deliveryHandle: {
254
+ provider: "email",
255
+ surface: "message_reply",
256
+ targetRef: fromParts ? (fromParts.address ?? fromParts.raw) : "",
257
+ threadRef: messageId ?? null,
258
+ replyMode: "reply",
259
+ },
260
+ };
261
+ }
262
+ function addressMatchesAllowlist(allowlist, fromAddress, toAddresses) {
263
+ const normalized = allowlist
264
+ .map((entry) => entry.trim().toLowerCase())
265
+ .filter((entry) => entry.length > 0);
266
+ if (normalized.length === 0) {
267
+ return true;
268
+ }
269
+ const lowerFrom = fromAddress?.toLowerCase() ?? null;
270
+ if (lowerFrom && normalized.includes(lowerFrom)) {
271
+ return true;
272
+ }
273
+ return toAddresses.some((address) => normalized.includes(address.toLowerCase()));
274
+ }
275
+ function occurredAtFromEmailDate(date) {
276
+ if (date) {
277
+ const parsed = Date.parse(date);
278
+ if (Number.isFinite(parsed)) {
279
+ return new Date(parsed).toISOString();
280
+ }
281
+ }
282
+ return new Date().toISOString();
283
+ }
284
+ class RpcEmailUxcClient {
285
+ client;
286
+ constructor(client = new uxc_daemon_client_1.UxcDaemonClient({ env: process.env })) {
287
+ this.client = client;
288
+ }
289
+ request(method, params) {
290
+ return this.client.request(method, params);
291
+ }
292
+ }
293
+ exports.RpcEmailUxcClient = RpcEmailUxcClient;
294
+ function emailDeliveryOperationsForHandle(handle) {
295
+ if (handle.provider !== "email") {
296
+ return [];
297
+ }
298
+ if (handle.surface !== "message_reply" && handle.surface !== "message_send") {
299
+ return [];
300
+ }
301
+ const isReply = handle.surface === "message_reply";
302
+ const commonProperties = {
303
+ text: { type: "string", minLength: 1 },
304
+ subject: { type: "string", minLength: 1 },
305
+ to: { type: "array", items: { type: "string", minLength: 1 } },
306
+ cc: { type: "array", items: { type: "string", minLength: 1 } },
307
+ from: { type: "string", minLength: 1 },
308
+ smtpEndpoint: { type: "string", minLength: 1 },
309
+ uxcAuth: { type: "string", minLength: 1 },
310
+ correlationKey: { type: "string", minLength: 1 },
311
+ };
312
+ const operations = [
313
+ {
314
+ name: "send_text",
315
+ title: isReply ? "Reply With Text" : "Send Text Email",
316
+ inputSchema: {
317
+ type: "object",
318
+ additionalProperties: false,
319
+ required: ["text", "subject"],
320
+ properties: commonProperties,
321
+ },
322
+ canonicalTextAlias: true,
323
+ },
324
+ ];
325
+ if (isReply) {
326
+ operations.push({
327
+ name: "reply_text",
328
+ title: "Reply With Text (Threaded)",
329
+ inputSchema: {
330
+ type: "object",
331
+ additionalProperties: false,
332
+ required: ["text"],
333
+ properties: {
334
+ ...commonProperties,
335
+ replyAll: { type: "boolean" },
336
+ inReplyTo: { type: "string", minLength: 1 },
337
+ },
338
+ },
339
+ });
340
+ }
341
+ return operations;
342
+ }
343
+ async function invokeEmailDeliveryOperation(handle, operation, input, options) {
344
+ if (handle.provider !== "email") {
345
+ throw new Error(`email delivery operation requires an email handle, got ${handle.provider}`);
346
+ }
347
+ if (operation !== "send_text" && operation !== "reply_text") {
348
+ throw new Error(`unknown email delivery operation: ${operation}`);
349
+ }
350
+ const isReply = operation === "reply_text" || handle.surface === "message_reply";
351
+ const text = asString(input.text);
352
+ if (!text || text.trim().length === 0) {
353
+ throw new Error(`${operation} requires input.text`);
354
+ }
355
+ const config = sourceConfigOrNull(options?.source);
356
+ const to = asStringArray(input.to) ?? (handle.targetRef ? [handle.targetRef] : []);
357
+ const cc = asStringArray(input.cc) ?? [];
358
+ if (to.length === 0 && cc.length === 0) {
359
+ throw new Error(`${operation} requires at least one recipient in input.to (or input.cc)`);
360
+ }
361
+ const subject = resolveEmailSubject(operation, input, isReply);
362
+ const smtpEndpoint = asString(input.smtpEndpoint) ?? config?.smtpEndpoint ?? null;
363
+ if (!smtpEndpoint) {
364
+ throw new Error(`${operation} requires input.smtpEndpoint or source config smtpEndpoint (uxc supports smtp://)`);
365
+ }
366
+ const from = asString(input.from) ?? config?.fromAddress ?? addressLikeAccount(config?.account) ?? null;
367
+ if (!from) {
368
+ throw new Error(`${operation} requires input.from, source config fromAddress, or an account-like config.account value`);
369
+ }
370
+ const uxcAuth = asString(input.uxcAuth) ?? config?.uxcAuth ?? null;
371
+ const correlationKey = asString(input.correlationKey);
372
+ const inReplyTo = asString(input.inReplyTo) ?? (isReply ? handle.threadRef ?? null : null);
373
+ const params = compactRecord({
374
+ smtp_url: smtpEndpoint,
375
+ from,
376
+ to,
377
+ cc: cc.length > 0 ? cc : undefined,
378
+ subject,
379
+ text,
380
+ in_reply_to: inReplyTo ?? undefined,
381
+ auth: uxcAuth ?? undefined,
382
+ message_id: correlationKey ? correlationMessageId(correlationKey) : undefined,
383
+ });
384
+ const method = isReply ? "email.reply" : "email.send";
385
+ if (isReply) {
386
+ params.reply_handle = compactRecord({
387
+ message_id: handle.threadRef ?? undefined,
388
+ account: config?.account ?? undefined,
389
+ mailbox: config?.mailbox ?? undefined,
390
+ });
391
+ }
392
+ const client = options?.client ?? new RpcEmailUxcClient();
393
+ let result;
394
+ try {
395
+ result = await client.request(method, params);
396
+ }
397
+ catch (error) {
398
+ throw wrapEmailRpcError(error, method);
399
+ }
400
+ const note = isReply
401
+ ? `sent email reply${result.message_id ? ` (message-id ${result.message_id})` : ""}`
402
+ : `sent email${result.message_id ? ` (message-id ${result.message_id})` : ""}`;
403
+ return { status: "sent", note };
404
+ }
405
+ function resolveEmailSubject(operation, input, isReply) {
406
+ const subject = asString(input.subject);
407
+ if (!isReply) {
408
+ if (!subject || subject.trim().length === 0) {
409
+ throw new Error(`${operation} requires input.subject`);
410
+ }
411
+ return subject;
412
+ }
413
+ const base = subject && subject.trim().length > 0 ? subject.trim() : "your message";
414
+ if (/^re:/i.test(base)) {
415
+ return base;
416
+ }
417
+ return `Re: ${base}`;
418
+ }
419
+ function correlationMessageId(correlationKey) {
420
+ const trimmed = correlationKey.trim();
421
+ if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
422
+ return trimmed;
423
+ }
424
+ const safe = trimmed.replace(/[<>\s]/g, "");
425
+ return `<agentinbox-${safe}@localhost>`;
426
+ }
427
+ function sourceConfigOrNull(source) {
428
+ if (!source || source.sourceType !== "email_mailbox") {
429
+ return null;
430
+ }
431
+ try {
432
+ return parseEmailMailboxSourceConfig(source);
433
+ }
434
+ catch {
435
+ return null;
436
+ }
437
+ }
438
+ function addressLikeAccount(account) {
439
+ if (!account) {
440
+ return null;
441
+ }
442
+ return account.includes("@") ? account : null;
443
+ }
444
+ function wrapEmailRpcError(error, method) {
445
+ if (error && typeof error === "object" && "code" in error) {
446
+ const code = error.code;
447
+ if (code === -32601 || code === -32602) {
448
+ return new Error(`connected uxc daemon does not support ${method} (code ${code}); upgrade uxc to a version with email send/reply RPC support`);
449
+ }
450
+ }
451
+ return error instanceof Error ? error : new Error(String(error));
452
+ }
453
+ class EmailDeliveryAdapter {
454
+ client;
455
+ constructor(client) {
456
+ this.client = client ?? new RpcEmailUxcClient();
457
+ }
458
+ async send(request, attempt) {
459
+ const handle = {
460
+ provider: attempt.provider,
461
+ surface: attempt.surface,
462
+ targetRef: attempt.targetRef,
463
+ threadRef: attempt.threadRef ?? null,
464
+ replyMode: attempt.replyMode ?? null,
465
+ };
466
+ const result = await invokeEmailDeliveryOperation(handle, "send_text", request.payload, {
467
+ client: this.client,
468
+ });
469
+ return { status: "sent", note: result.note };
470
+ }
471
+ }
472
+ exports.EmailDeliveryAdapter = EmailDeliveryAdapter;
473
+ function compactRecord(input) {
474
+ const output = {};
475
+ for (const [key, value] of Object.entries(input)) {
476
+ if (value === undefined || value === null) {
477
+ continue;
478
+ }
479
+ if (Array.isArray(value) && value.length === 0) {
480
+ continue;
481
+ }
482
+ output[key] = value;
483
+ }
484
+ return output;
485
+ }
486
+ function asRecord(value) {
487
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
488
+ return {};
489
+ }
490
+ return value;
491
+ }
492
+ function asString(value) {
493
+ return typeof value === "string" ? value : null;
494
+ }
495
+ function asStringArray(value) {
496
+ if (Array.isArray(value)) {
497
+ return value
498
+ .map((item) => stringFromUnknown(item))
499
+ .filter((item) => Boolean(item && item.trim().length > 0));
500
+ }
501
+ if (typeof value === "string") {
502
+ return value
503
+ .split(",")
504
+ .map((item) => item.trim())
505
+ .filter((item) => item.length > 0);
506
+ }
507
+ return null;
508
+ }
509
+ function stringFromUnknown(value) {
510
+ if (typeof value === "string") {
511
+ return value;
512
+ }
513
+ if (typeof value === "number" && Number.isFinite(value)) {
514
+ return String(value);
515
+ }
516
+ if (typeof value === "bigint") {
517
+ return value.toString();
518
+ }
519
+ return null;
520
+ }
521
+ function numberFromUnknown(value) {
522
+ if (typeof value === "number" && Number.isFinite(value)) {
523
+ return value;
524
+ }
525
+ if (typeof value === "string" && value.trim().length > 0) {
526
+ const parsed = Number(value);
527
+ if (Number.isFinite(parsed)) {
528
+ return parsed;
529
+ }
530
+ }
531
+ return undefined;
532
+ }
@@ -11,6 +11,7 @@ const REMOTE_SOURCE_TYPES = new Set([
11
11
  "github_repo_ci",
12
12
  "feishu_bot",
13
13
  "telegram_bot",
14
+ "email_mailbox",
14
15
  ]);
15
16
  const DEFAULT_SYNC_INTERVAL_MS = 2_000;
16
17
  const DEFAULT_BACKOFF_BASE_SECS = 2;
@@ -15,9 +15,10 @@ const github_1 = require("./github");
15
15
  const github_ci_1 = require("./github_ci");
16
16
  const feishu_1 = require("./feishu");
17
17
  const telegram_1 = require("./telegram");
18
+ const email_1 = require("./email");
18
19
  const REMOTE_USER_MODULE_ROOT_DIR = "source-modules";
19
- const BUILTIN_MODULE_IDS = new Set(["builtin.github_repo", "builtin.github_repo_ci", "builtin.feishu_bot", "builtin.telegram_bot"]);
20
- const BUILTIN_REMOTE_SOURCE_TYPES = ["github_repo", "github_repo_ci", "feishu_bot", "telegram_bot"];
20
+ const BUILTIN_MODULE_IDS = new Set(["builtin.github_repo", "builtin.github_repo_ci", "builtin.feishu_bot", "builtin.telegram_bot", "builtin.email_mailbox"]);
21
+ const BUILTIN_REMOTE_SOURCE_TYPES = ["github_repo", "github_repo_ci", "feishu_bot", "telegram_bot", "email_mailbox"];
21
22
  class RemoteSourceModuleRegistry {
22
23
  moduleCache = new Map();
23
24
  githubRepoModule;
@@ -37,6 +38,9 @@ class RemoteSourceModuleRegistry {
37
38
  if (source.sourceType === "telegram_bot") {
38
39
  return Promise.resolve(TELEGRAM_BOT_MODULE);
39
40
  }
41
+ if (source.sourceType === "email_mailbox") {
42
+ return Promise.resolve(EMAIL_MAILBOX_MODULE);
43
+ }
40
44
  if (source.sourceType !== "remote_source") {
41
45
  throw new Error(`unsupported source type for remote module: ${source.sourceType}`);
42
46
  }
@@ -86,6 +90,9 @@ function builtInModuleIdForSourceType(sourceType) {
86
90
  if (sourceType === "telegram_bot") {
87
91
  return "builtin.telegram_bot";
88
92
  }
93
+ if (sourceType === "email_mailbox") {
94
+ return "builtin.email_mailbox";
95
+ }
89
96
  return null;
90
97
  }
91
98
  function builtinRemoteSourceTypes() {
@@ -631,6 +638,73 @@ const TELEGRAM_BOT_MODULE = {
631
638
  };
632
639
  },
633
640
  };
641
+ const EMAIL_MAILBOX_MODULE = {
642
+ id: "builtin.email_mailbox",
643
+ listDeliveryOperations(input) {
644
+ return (0, email_1.emailDeliveryOperationsForHandle)(input.handle);
645
+ },
646
+ async invokeDeliveryOperation(input) {
647
+ return (0, email_1.invokeEmailDeliveryOperation)(input.handle, input.operation, input.input, {
648
+ source: input.source,
649
+ });
650
+ },
651
+ describeCapabilities() {
652
+ return {
653
+ sourceKind: "email_mailbox",
654
+ aliases: ["email_mailbox", "email"],
655
+ configSchema: [
656
+ { name: "provider", type: "string", required: true, description: "Email provider: imap, gmail, graph, or jmap." },
657
+ { name: "uxcAuth", type: "string", required: true, description: "UXC auth profile name holding mailbox credentials." },
658
+ { name: "endpoint", type: "string", required: false, description: "imap:// or imaps:// endpoint for imap; provider API endpoint otherwise (required for jmap)." },
659
+ { name: "account", type: "string", required: false, description: "Account alias; defaults to the auth profile username." },
660
+ { name: "mailbox", type: "string", required: false, description: "Mailbox to watch; defaults to INBOX." },
661
+ { name: "pollIntervalSecs", type: "number", required: false, description: "Provider poll interval in seconds (min 15, default 60)." },
662
+ { name: "smtpEndpoint", type: "string", required: false, description: "Default smtp:// endpoint for outbound delivery." },
663
+ { name: "fromAddress", type: "string", required: false, description: "Default outbound From address." },
664
+ { name: "addressAllowlist", type: "string[]", required: false, description: "Optional from/to address allowlist." },
665
+ ],
666
+ metadataFields: [
667
+ { name: "provider", type: "string", description: "Email provider." },
668
+ { name: "account", type: "string", description: "Mailbox account alias." },
669
+ { name: "mailbox", type: "string", description: "Mailbox name." },
670
+ { name: "messageId", type: "string|null", description: "RFC Message-ID when present." },
671
+ { name: "providerMessageId", type: "string|null", description: "Provider-native message id." },
672
+ { name: "threadId", type: "string|null", description: "Thread reference from References/In-Reply-To headers." },
673
+ { name: "from", type: "string|null", description: "Normalized sender address." },
674
+ { name: "fromName", type: "string|null", description: "Sender display name." },
675
+ { name: "to", type: "object[]", description: "Normalized recipients." },
676
+ { name: "cc", type: "object[]", description: "Normalized cc recipients." },
677
+ { name: "subject", type: "string|null", description: "Message subject." },
678
+ { name: "textPreview", type: "string|null", description: "Body snippet." },
679
+ { name: "hasAttachments", type: "boolean", description: "Whether the message carries attachments." },
680
+ { name: "attachmentCount", type: "number|null", description: "Attachment count; null when provider metadata was not expanded." },
681
+ { name: "attachments", type: "object[]", description: "Attachment metadata with opaque retrieval handles." },
682
+ ],
683
+ eventVariantExamples: ["email.message.received"],
684
+ };
685
+ },
686
+ validateConfig(source) {
687
+ (0, email_1.parseEmailMailboxSourceConfig)(source);
688
+ },
689
+ buildManagedSourceSpec(source) {
690
+ return (0, email_1.buildEmailMailboxSourceSpec)((0, email_1.parseEmailMailboxSourceConfig)(source));
691
+ },
692
+ mapRawEvent(rawPayload, source) {
693
+ const config = (0, email_1.parseEmailMailboxSourceConfig)(source);
694
+ const normalized = (0, email_1.normalizeEmailMailboxEvent)(source, config, rawPayload);
695
+ if (!normalized) {
696
+ return null;
697
+ }
698
+ return {
699
+ sourceNativeId: normalized.sourceNativeId,
700
+ eventVariant: normalized.eventVariant,
701
+ metadata: normalized.metadata ?? {},
702
+ rawPayload: normalized.rawPayload ?? rawPayload,
703
+ occurredAt: normalized.occurredAt,
704
+ deliveryHandle: normalized.deliveryHandle,
705
+ };
706
+ },
707
+ };
634
708
  function moduleConfigForSource(source) {
635
709
  const config = asRecord(source.config);
636
710
  if (source.sourceType === "remote_source") {
package/dist/src/store.js CHANGED
@@ -40,24 +40,29 @@ class AgentInboxStore {
40
40
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(dbPath), { recursive: true });
41
41
  removeOrphanBackupTmps(dbPath);
42
42
  const existedBeforeOpen = node_fs_1.default.existsSync(dbPath);
43
- let db = await this.openDatabaseWithRecovery(dbPath);
43
+ // Legacy AGENTINBOX_STARTUP_BACKUP=1 mode pays for a full check and an
44
+ // unconditional backup on every open, matching v1.5.2 behavior.
45
+ const legacyStartupBackup = legacyStartupBackupEnabled(env);
46
+ let db = await this.openDatabaseWithRecovery(dbPath, legacyStartupBackup);
44
47
  let store = new AgentInboxStore(dbPath, db);
48
+ let archivedPreV1 = false;
45
49
  if (existedBeforeOpen && store.shouldArchivePreV1Database()) {
46
50
  const archivedPath = store.archivePreV1Database();
47
51
  console.warn(`[agentinbox] archived pre-v1 local database to ${archivedPath}; starting with a fresh v1 database (no data imported).`);
48
52
  db = this.openDatabase(dbPath);
49
53
  store = new AgentInboxStore(dbPath, db);
54
+ archivedPreV1 = true;
50
55
  }
51
- else if (existedBeforeOpen && startupBackupEnabled(env)) {
52
- await store.backupHealthyDatabase();
56
+ else if (existedBeforeOpen && legacyStartupBackup) {
57
+ await store.backupDatabase();
53
58
  }
54
- store.migrate();
59
+ await store.migrate({ existedBeforeOpen: existedBeforeOpen && !archivedPreV1, env });
55
60
  store.persist();
56
61
  return store;
57
62
  }
58
- static async openDatabaseWithRecovery(dbPath) {
63
+ static async openDatabaseWithRecovery(dbPath, fullCheck) {
59
64
  try {
60
- return this.openDatabase(dbPath);
65
+ return this.openDatabase(dbPath, fullCheck);
61
66
  }
62
67
  catch (error) {
63
68
  if (!node_fs_1.default.existsSync(dbPath)) {
@@ -71,19 +76,29 @@ class AgentInboxStore {
71
76
  node_fs_1.default.renameSync(dbPath, corruptPath);
72
77
  node_fs_1.default.copyFileSync(backupPath, dbPath);
73
78
  console.warn(`[agentinbox] recovered local database from ${backupPath}; archived corrupt database to ${corruptPath}.`);
74
- return this.openDatabase(dbPath);
79
+ return this.openDatabase(dbPath, fullCheck);
75
80
  }
76
81
  }
77
- static openDatabase(dbPath) {
82
+ static openDatabase(dbPath, fullCheck = false) {
78
83
  const db = new better_sqlite3_1.default(dbPath);
79
84
  db.pragma("busy_timeout = 5000");
80
85
  db.pragma("journal_mode = WAL");
81
86
  db.pragma("synchronous = NORMAL");
82
87
  db.pragma("foreign_keys = ON");
83
- this.assertHealthy(db, dbPath);
88
+ this.assertHealthy(db, dbPath, fullCheck);
84
89
  return db;
85
90
  }
86
- static assertHealthy(db, dbPath) {
91
+ /**
92
+ * Verifies database integrity. Regular opens run `quick_check` and only
93
+ * escalate to a full `integrity_check` when the quick pass fails, so large
94
+ * databases do not pay a full scan on every startup. Conservative callers
95
+ * (legacy `AGENTINBOX_STARTUP_BACKUP=1` mode, recovery-candidate
96
+ * validation) pass `fullCheck`.
97
+ */
98
+ static assertHealthy(db, dbPath, fullCheck) {
99
+ if (!fullCheck && db.pragma("quick_check", { simple: true }) === "ok") {
100
+ return;
101
+ }
87
102
  const result = db.pragma("integrity_check", { simple: true });
88
103
  if (result !== "ok") {
89
104
  db.close();
@@ -95,7 +110,7 @@ class AgentInboxStore {
95
110
  for (const candidate of candidates) {
96
111
  try {
97
112
  const db = new better_sqlite3_1.default(candidate, { readonly: true, fileMustExist: true });
98
- this.assertHealthy(db, candidate);
113
+ this.assertHealthy(db, candidate, true);
99
114
  db.close();
100
115
  return candidate;
101
116
  }
@@ -108,14 +123,32 @@ class AgentInboxStore {
108
123
  static listBackupCandidates(dbPath) {
109
124
  const dir = node_path_1.default.dirname(dbPath);
110
125
  const baseName = node_path_1.default.basename(dbPath);
111
- const startupBackups = node_fs_1.default.existsSync(dir)
112
- ? node_fs_1.default.readdirSync(dir)
113
- .filter((name) => name.startsWith(`${baseName}.startup.`) && name.endsWith(".bak"))
114
- .sort()
115
- .reverse()
116
- .map((name) => node_path_1.default.join(dir, name))
117
- : [];
118
- return [`${dbPath}.bak`, ...startupBackups].filter((candidate, index, all) => node_fs_1.default.existsSync(candidate) && all.indexOf(candidate) === index);
126
+ const preMigrationPattern = new RegExp(`^${escapeRegExp(baseName)}\\.pre-migrate-v(\\d+)\\.bak$`);
127
+ let entries;
128
+ try {
129
+ entries = node_fs_1.default.readdirSync(dir);
130
+ }
131
+ catch {
132
+ return [];
133
+ }
134
+ const candidates = [];
135
+ for (const name of entries) {
136
+ const candidatePath = node_path_1.default.join(dir, name);
137
+ if (name === `${baseName}.bak`) {
138
+ candidates.push({ path: candidatePath, version: 0, modifiedAtMs: backupMtimeMs(candidatePath) });
139
+ continue;
140
+ }
141
+ const preMigration = preMigrationPattern.exec(name);
142
+ if (preMigration) {
143
+ candidates.push({
144
+ path: candidatePath,
145
+ version: Number.parseInt(preMigration[1], 10),
146
+ modifiedAtMs: backupMtimeMs(candidatePath),
147
+ });
148
+ }
149
+ }
150
+ candidates.sort((left, right) => right.modifiedAtMs - left.modifiedAtMs || right.version - left.version);
151
+ return candidates.map((candidate) => candidate.path);
119
152
  }
120
153
  static nextPath(base) {
121
154
  if (!node_fs_1.default.existsSync(base)) {
@@ -135,13 +168,22 @@ class AgentInboxStore {
135
168
  }
136
169
  getDatabaseHealth() {
137
170
  return {
138
- integrityCheck: String(this.db.pragma("integrity_check", { simple: true })),
171
+ quickCheck: String(this.db.pragma("quick_check", { simple: true })),
139
172
  journalMode: String(this.db.pragma("journal_mode", { simple: true })),
140
173
  foreignKeys: Number(this.db.pragma("foreign_keys", { simple: true })) === 1,
141
174
  };
142
175
  }
143
- async backupHealthyDatabase() {
176
+ /**
177
+ * Writes a snapshot to `<db>.bak`, used by `agentinbox backup` and by the
178
+ * opt-in legacy `AGENTINBOX_STARTUP_BACKUP=1` startup backup. The open-time
179
+ * health check has already run against this handle. Returns the backup path.
180
+ */
181
+ async backupDatabase() {
144
182
  const backupPath = `${this.dbPath}.bak`;
183
+ await this.writeBackupTo(backupPath);
184
+ return backupPath;
185
+ }
186
+ async writeBackupTo(backupPath) {
145
187
  const tmpPath = `${backupPath}.${process.pid}.tmp`;
146
188
  try {
147
189
  node_fs_1.default.rmSync(tmpPath, { force: true });
@@ -152,17 +194,57 @@ class AgentInboxStore {
152
194
  node_fs_1.default.rmSync(tmpPath, { force: true });
153
195
  }
154
196
  }
155
- migrate() {
197
+ async migrate(options) {
156
198
  const migrations = this.loadSqlMigrations();
157
199
  this.ensureDrizzleMigrationsTable();
158
200
  const applied = this.listAppliedMigrationTags();
159
201
  const pending = migrations.filter((migration) => !applied.has(migration.tag));
202
+ if (options.existedBeforeOpen && pending.length > 0) {
203
+ const backupPath = await this.backupBeforeMigration(migrations.length);
204
+ console.warn(`[agentinbox] pending schema migrations detected; backed up the local database to ${backupPath} before migrating to schema v${migrations.length}.`);
205
+ this.pruneMigrationBackups(options.env);
206
+ }
160
207
  for (const migration of pending) {
161
208
  this.applyMigration(migration);
162
209
  }
163
210
  this.ensureInboxEntryBackfill();
164
211
  this.setUserVersion(migrations.length);
165
212
  }
213
+ async backupBeforeMigration(targetVersion) {
214
+ const backupPath = `${this.dbPath}.pre-migrate-v${targetVersion}.bak`;
215
+ await this.writeBackupTo(backupPath);
216
+ return backupPath;
217
+ }
218
+ /**
219
+ * Keeps only the newest `AGENTINBOX_MIGRATION_BACKUP_KEEP` pre-migration
220
+ * backups (default 5; values <= 0 keep everything). Migration backups are
221
+ * rare, so pruning only runs right after a new one is written.
222
+ */
223
+ pruneMigrationBackups(env) {
224
+ const keep = migrationBackupKeep(env);
225
+ if (keep <= 0) {
226
+ return;
227
+ }
228
+ const dir = node_path_1.default.dirname(this.dbPath);
229
+ const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(this.dbPath))}\\.pre-migrate-v(\\d+)\\.bak$`);
230
+ const entries = [];
231
+ for (const name of node_fs_1.default.readdirSync(dir)) {
232
+ const match = pattern.exec(name);
233
+ if (!match) {
234
+ continue;
235
+ }
236
+ entries.push({ path: node_path_1.default.join(dir, name), version: Number.parseInt(match[1], 10) });
237
+ }
238
+ entries.sort((left, right) => right.version - left.version);
239
+ for (const entry of entries.slice(keep)) {
240
+ try {
241
+ node_fs_1.default.rmSync(entry.path, { force: true });
242
+ }
243
+ catch {
244
+ // Best-effort pruning.
245
+ }
246
+ }
247
+ }
166
248
  loadSqlMigrations() {
167
249
  const migrationsDir = this.resolveMigrationsDir();
168
250
  const files = node_fs_1.default
@@ -2384,16 +2466,37 @@ function uniqueSorted(values) {
2384
2466
  function summarizeBackfilledItemEntry(item) {
2385
2467
  return `${item.eventVariant} from ${item.sourceId}`;
2386
2468
  }
2387
- function startupBackupEnabled(env) {
2388
- return !(0, util_1.isEnvFlagDisabled)(env.AGENTINBOX_STARTUP_BACKUP);
2469
+ const DEFAULT_MIGRATION_BACKUP_KEEP = 5;
2470
+ /**
2471
+ * Backups are event-driven: a full database backup is taken only before
2472
+ * pending schema migrations run, plus explicit `agentinbox backup` snapshots.
2473
+ * Setting `AGENTINBOX_STARTUP_BACKUP=1` opts back into the legacy v1.5.2
2474
+ * behavior of an unconditional backup and a full `integrity_check` on every
2475
+ * open.
2476
+ */
2477
+ function legacyStartupBackupEnabled(env) {
2478
+ return (0, util_1.isEnvFlagEnabled)(env.AGENTINBOX_STARTUP_BACKUP);
2479
+ }
2480
+ function migrationBackupKeep(env) {
2481
+ const parsed = Number.parseInt(env.AGENTINBOX_MIGRATION_BACKUP_KEEP ?? "", 10);
2482
+ return Number.isInteger(parsed) ? parsed : DEFAULT_MIGRATION_BACKUP_KEEP;
2483
+ }
2484
+ function backupMtimeMs(candidatePath) {
2485
+ try {
2486
+ return node_fs_1.default.statSync(candidatePath).mtimeMs;
2487
+ }
2488
+ catch {
2489
+ return 0;
2490
+ }
2389
2491
  }
2390
2492
  /**
2391
- * Removes leftover `<db>.bak.<pid>.tmp` files whose owning process is dead.
2392
- * Tmp files owned by a live pid belong to a concurrent open and are preserved.
2493
+ * Removes leftover `<db>.*.bak.<pid>.tmp` files (manual and pre-migration
2494
+ * backup temporaries) whose owning process is dead. Tmp files owned by a live
2495
+ * pid belong to a concurrent open and are preserved.
2393
2496
  */
2394
2497
  function removeOrphanBackupTmps(dbPath) {
2395
2498
  const dir = node_path_1.default.dirname(dbPath);
2396
- const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(dbPath))}\\.bak\\.(\\d+)\\.tmp$`);
2499
+ const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(dbPath))}\\.(?:.+\\.)?bak\\.(\\d+)\\.tmp$`);
2397
2500
  let entries;
2398
2501
  try {
2399
2502
  entries = node_fs_1.default.readdirSync(dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "1.5.2",
3
+ "version": "1.7.0",
4
4
  "description": "Local event subscription and delivery service for agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {