@holon-run/agentinbox 1.8.0 → 1.9.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.
@@ -213,6 +213,12 @@ class AdapterRegistry {
213
213
  }
214
214
  return this.remoteSource.summarizeDigestThread(source, items, grouping);
215
215
  }
216
+ async shouldFlushDigestThread(source, items, context) {
217
+ if (source.sourceType === "local_event") {
218
+ return null;
219
+ }
220
+ return this.remoteSource.shouldFlushDigestThread(source, items, context);
221
+ }
216
222
  async listDeliveryOperations(source, handle) {
217
223
  const module = await this.resolveDeliveryModule(source, handle);
218
224
  if (!module?.listDeliveryOperations) {
@@ -30,6 +30,7 @@ const DEFAULT_IDLE_SOURCE_GRACE_MS = 30 * 60 * 1000;
30
30
  const DEFAULT_INBOX_AGGREGATION_WINDOW_MS = 30 * 1000;
31
31
  const DEFAULT_INBOX_AGGREGATION_MAX_ITEMS = 20;
32
32
  const DEFAULT_INBOX_AGGREGATION_MAX_THREAD_AGE_MS = 24 * 60 * 60 * 1000;
33
+ const DEFAULT_DIGEST_THREAD_RECHECK_MS = 30_000;
33
34
  const DIRECT_INBOX_SOURCE_ID = "__agentinbox_direct_text__";
34
35
  const DIRECT_INBOX_EVENT_VARIANT = "agentinbox.direct_text_message";
35
36
  const TIMER_INBOX_SOURCE_ID = "__agentinbox_timer__";
@@ -1339,7 +1340,10 @@ class AgentInboxService {
1339
1340
  this.store.closeDigestThread(thread.threadId, now);
1340
1341
  thread = null;
1341
1342
  }
1342
- const flushAfterAt = new Date(Date.now() + (policy.windowMs ?? DEFAULT_INBOX_AGGREGATION_WINDOW_MS)).toISOString();
1343
+ const groupingFlushDelayMs = grouping.flushDelayMs != null && grouping.flushDelayMs >= 0
1344
+ ? grouping.flushDelayMs
1345
+ : null;
1346
+ const flushAfterAt = new Date(Date.now() + (groupingFlushDelayMs ?? policy.windowMs ?? DEFAULT_INBOX_AGGREGATION_WINDOW_MS)).toISOString();
1343
1347
  if (!thread) {
1344
1348
  thread = this.store.createDigestThread({
1345
1349
  inboxId: input.inbox.inboxId,
@@ -1621,7 +1625,7 @@ class AgentInboxService {
1621
1625
  source,
1622
1626
  subscriptionId: subscription.subscriptionId,
1623
1627
  item,
1624
- summary: summarizeSourceEvent(source.sourceType, source.sourceKey, event.eventVariant),
1628
+ summary: summarizeSourceEvent(source.sourceType, source.sourceKey, event.eventVariant, event.metadata),
1625
1629
  });
1626
1630
  insertedEntries.push(...entries);
1627
1631
  for (const entry of entries) {
@@ -1953,6 +1957,7 @@ class AgentInboxService {
1953
1957
  const inbox = {
1954
1958
  inboxId: (0, util_1.generateCanonicalId)("inb"),
1955
1959
  ownerAgentId: agentId,
1960
+ aggregationEnabled: true,
1956
1961
  createdAt: (0, util_1.nowIso)(),
1957
1962
  };
1958
1963
  this.store.insertInbox(inbox);
@@ -3199,6 +3204,21 @@ class AgentInboxService {
3199
3204
  this.store.closeDigestThread(thread.threadId, now);
3200
3205
  continue;
3201
3206
  }
3207
+ const pendingItems = this.store.listUnackedInboxItemsForDigestThread(thread.threadId);
3208
+ if (pendingItems.length > 0) {
3209
+ const decision = await this.adapters.shouldFlushDigestThread(source, pendingItems.map((item) => activationItemFromInboxItem(item)), {
3210
+ threadCreatedAt: thread.createdAt,
3211
+ lastItemAt: thread.lastItemAt,
3212
+ now,
3213
+ });
3214
+ if (decision && !decision.flush) {
3215
+ const recheckAfterMs = decision.recheckAfterMs != null && decision.recheckAfterMs > 0
3216
+ ? decision.recheckAfterMs
3217
+ : DEFAULT_DIGEST_THREAD_RECHECK_MS;
3218
+ this.store.updateDigestThreadFlushAfterAt(thread.threadId, new Date(Date.parse(now) + recheckAfterMs).toISOString());
3219
+ continue;
3220
+ }
3221
+ }
3202
3222
  const entry = await this.flushDigestThread(source, thread.threadId);
3203
3223
  if (!entry) {
3204
3224
  continue;
@@ -3945,7 +3965,27 @@ function extractHttpStatus(error) {
3945
3965
  }
3946
3966
  return null;
3947
3967
  }
3948
- function summarizeSourceEvent(sourceType, sourceKey, eventVariant) {
3968
+ function summarizeSourceEvent(sourceType, sourceKey, eventVariant, metadata = {}) {
3969
+ if (sourceType === "github_repo") {
3970
+ const repo = asNonEmptyString(metadata.repoFullName) ?? sourceKey;
3971
+ const number = typeof metadata.number === "number" && Number.isInteger(metadata.number)
3972
+ ? metadata.number
3973
+ : null;
3974
+ const isPullRequest = metadata.isPullRequest === true;
3975
+ const resource = number == null ? null : `${isPullRequest ? "PR" : "Issue"} #${number}`;
3976
+ const action = asNonEmptyString(metadata.action) ?? eventVariant.split(".")[1] ?? "updated";
3977
+ const title = asNonEmptyString(metadata.title);
3978
+ const author = asNonEmptyString(metadata.author);
3979
+ const url = asNonEmptyString(metadata.url);
3980
+ const details = [
3981
+ resource,
3982
+ action,
3983
+ title ? `"${truncateSummary(title, 160)}"` : null,
3984
+ author ? `by @${author}` : null,
3985
+ url,
3986
+ ].filter((value) => value != null);
3987
+ return `${sourceType}:${repo}${details.length > 0 ? ` ${details.join(" ")}` : `:${eventVariant}`}`;
3988
+ }
3949
3989
  if (sourceType === "github_repo_ci") {
3950
3990
  const parts = eventVariant.split(".");
3951
3991
  const [, second, third, fourth] = parts;
@@ -3967,6 +4007,19 @@ function summarizeSourceEvent(sourceType, sourceKey, eventVariant) {
3967
4007
  }
3968
4008
  return `${sourceType}:${sourceKey}:${eventVariant}`;
3969
4009
  }
4010
+ function asNonEmptyString(value) {
4011
+ if (typeof value !== "string") {
4012
+ return null;
4013
+ }
4014
+ const trimmed = value.trim();
4015
+ return trimmed.length > 0 ? trimmed : null;
4016
+ }
4017
+ function truncateSummary(value, maxChars) {
4018
+ if (value.length <= maxChars) {
4019
+ return value;
4020
+ }
4021
+ return `${value.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
4022
+ }
3970
4023
  function summarizeDirectInboxMessage(sender) {
3971
4024
  if (sender) {
3972
4025
  return `direct_text_message:${sender}`;
@@ -237,6 +237,8 @@ const SOURCE_SCHEMAS = {
237
237
  { name: "mailbox", type: "string", required: false, description: "Mailbox to watch; defaults to INBOX." },
238
238
  { name: "pollIntervalSecs", type: "number", required: false, description: "Provider poll interval in seconds (min 15, default 60)." },
239
239
  { name: "initialFetchLimit", type: "number", required: false, description: "First-look backfill depth: 0 = new mail only, max 100, default 25." },
240
+ { name: "method", type: "string", required: false, description: "Provider poll HTTP method: get (default) or post; ignored for imap." },
241
+ { name: "requestBody", type: "object", required: false, description: "JSON request body for provider polling; requires method=post and is ignored for imap." },
240
242
  { name: "smtpEndpoint", type: "string", required: false, description: "Default smtp:// endpoint for outbound reply/send delivery." },
241
243
  { name: "fromAddress", type: "string", required: false, description: "Default outbound From address for delivery." },
242
244
  { name: "addressAllowlist", type: "string[]", required: false, description: "Optional from/to address allowlist applied before inbox routing." },
@@ -104,6 +104,25 @@ function parseEmailMailboxSourceConfig(source) {
104
104
  }
105
105
  const addressAllowlist = asStringArray(config.addressAllowlist ?? config.addressAllowList);
106
106
  const attachmentPolicy = parseEmailAttachmentPolicy(config.attachmentPolicy ?? config.attachment_policy);
107
+ const methodRaw = config.method;
108
+ let method = "get";
109
+ if (methodRaw !== undefined && methodRaw !== null) {
110
+ if (typeof methodRaw !== "string" || !["get", "post"].includes(methodRaw.trim().toLowerCase())) {
111
+ throw new Error(`email_mailbox config.method must be get or post; got ${String(methodRaw)}`);
112
+ }
113
+ method = methodRaw.trim().toLowerCase();
114
+ }
115
+ const requestBodyRaw = config.requestBody;
116
+ let requestBody;
117
+ if (requestBodyRaw !== undefined && requestBodyRaw !== null) {
118
+ if (typeof requestBodyRaw !== "object" || Array.isArray(requestBodyRaw)) {
119
+ throw new Error("email_mailbox config.requestBody must be a JSON object when provided");
120
+ }
121
+ if (method !== "post") {
122
+ throw new Error("email_mailbox config.requestBody requires config.method=post");
123
+ }
124
+ requestBody = requestBodyRaw;
125
+ }
107
126
  return {
108
127
  provider: provider,
109
128
  endpoint,
@@ -112,6 +131,8 @@ function parseEmailMailboxSourceConfig(source) {
112
131
  mailbox,
113
132
  pollIntervalSecs,
114
133
  initialFetchLimit,
134
+ method,
135
+ ...(requestBody ? { requestBody } : {}),
115
136
  ...(smtpEndpoint ? { smtpEndpoint } : {}),
116
137
  ...(fromAddress ? { fromAddress } : {}),
117
138
  ...(addressAllowlist && addressAllowlist.length > 0 ? { addressAllowlist } : {}),
@@ -171,7 +192,9 @@ function buildEmailMailboxSourceSpec(config) {
171
192
  transport_hint: "email_provider_poll",
172
193
  args: {
173
194
  provider: config.provider,
195
+ method: config.method,
174
196
  ...args,
197
+ ...(config.requestBody ? { body: config.requestBody } : {}),
175
198
  },
176
199
  poll_config: pollConfig,
177
200
  options: {
@@ -1,12 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GithubCiSourceRuntime = exports.GithubActionsUxcClient = exports.DEFAULT_GITHUB_CI_PER_PAGE = exports.DEFAULT_GITHUB_CI_POLL_INTERVAL_SECS = exports.GITHUB_CI_ENDPOINT = void 0;
3
+ exports.GithubCiSourceRuntime = exports.GithubActionsUxcClient = exports.DEFAULT_GITHUB_CI_DIGEST_FLUSH_TIMEOUT_SECS = exports.DEFAULT_GITHUB_CI_DIGEST_QUIET_WINDOW_SECS = exports.DEFAULT_GITHUB_CI_PER_PAGE = exports.DEFAULT_GITHUB_CI_POLL_INTERVAL_SECS = exports.GITHUB_CI_ENDPOINT = void 0;
4
4
  exports.normalizeGithubWorkflowRunEvent = normalizeGithubWorkflowRunEvent;
5
5
  exports.parseGithubCiSourceConfig = parseGithubCiSourceConfig;
6
+ exports.githubCiDigestQuietWindowMs = githubCiDigestQuietWindowMs;
7
+ exports.githubCiDigestFlushTimeoutMs = githubCiDigestFlushTimeoutMs;
8
+ exports.deriveGithubCiNotificationGrouping = deriveGithubCiNotificationGrouping;
9
+ exports.decideGithubCiDigestFlush = decideGithubCiDigestFlush;
10
+ exports.summarizeGithubCiDigestThread = summarizeGithubCiDigestThread;
6
11
  const uxc_daemon_client_1 = require("@holon-run/uxc-daemon-client");
7
12
  exports.GITHUB_CI_ENDPOINT = "https://api.github.com";
8
13
  exports.DEFAULT_GITHUB_CI_POLL_INTERVAL_SECS = 30;
9
14
  exports.DEFAULT_GITHUB_CI_PER_PAGE = 20;
15
+ exports.DEFAULT_GITHUB_CI_DIGEST_QUIET_WINDOW_SECS = 120;
16
+ exports.DEFAULT_GITHUB_CI_DIGEST_FLUSH_TIMEOUT_SECS = 60 * 60;
17
+ const GITHUB_CI_DIGEST_RECHECK_MS = 30_000;
18
+ const GITHUB_CI_DIGEST_MIN_RECHECK_MS = 1_000;
10
19
  const MAX_PAGES_PER_SYNC = 10;
11
20
  const MAX_SEEN_KEYS = 512;
12
21
  const MAX_ERROR_BACKOFF_MULTIPLIER = 8;
@@ -335,6 +344,8 @@ function parseGithubCiSourceConfig(source) {
335
344
  eventFilter: asString(config.eventFilter) ?? undefined,
336
345
  branch: asString(config.branch) ?? undefined,
337
346
  statusFilter: asString(config.statusFilter) ?? undefined,
347
+ digestQuietWindowSecs: asNumber(config.digestQuietWindowSecs) ?? undefined,
348
+ digestFlushTimeoutSecs: asNumber(config.digestFlushTimeoutSecs) ?? undefined,
338
349
  };
339
350
  }
340
351
  return {
@@ -346,8 +357,207 @@ function parseGithubCiSourceConfig(source) {
346
357
  eventFilter: asString(config.eventFilter) ?? undefined,
347
358
  branch: asString(config.branch) ?? undefined,
348
359
  statusFilter: asString(config.statusFilter) ?? undefined,
360
+ digestQuietWindowSecs: asNumber(config.digestQuietWindowSecs) ?? undefined,
361
+ digestFlushTimeoutSecs: asNumber(config.digestFlushTimeoutSecs) ?? undefined,
349
362
  };
350
363
  }
364
+ function githubCiDigestQuietWindowMs(config) {
365
+ return Math.max(0, (config.digestQuietWindowSecs ?? exports.DEFAULT_GITHUB_CI_DIGEST_QUIET_WINDOW_SECS) * 1000);
366
+ }
367
+ function githubCiDigestFlushTimeoutMs(config) {
368
+ return Math.max(0, (config.digestFlushTimeoutSecs ?? exports.DEFAULT_GITHUB_CI_DIGEST_FLUSH_TIMEOUT_SECS) * 1000);
369
+ }
370
+ function firstPullRequestNumber(item) {
371
+ const numbers = item.metadata.pullRequestNumbers;
372
+ if (!Array.isArray(numbers)) {
373
+ return null;
374
+ }
375
+ for (const number of numbers) {
376
+ if (typeof number === "number" && Number.isInteger(number) && number > 0) {
377
+ return number;
378
+ }
379
+ }
380
+ return null;
381
+ }
382
+ /**
383
+ * Groups workflow-run notifications at the pull-request level so a push that
384
+ * fans out into many workflow runs produces one digest thread per PR instead
385
+ * of one per run. Runs without a PR association fall back to per-push grouping
386
+ * by head sha.
387
+ */
388
+ function deriveGithubCiNotificationGrouping(item, config) {
389
+ const workflowRunId = asNumber(item.metadata.workflowRunId);
390
+ if (!workflowRunId) {
391
+ return null;
392
+ }
393
+ const repoFullName = asString(item.metadata.repoFullName) ?? `${config.owner}/${config.repo}`;
394
+ const headSha = asString(item.metadata.headSha);
395
+ const prNumber = firstPullRequestNumber(item);
396
+ const resourceRef = prNumber != null
397
+ ? `pr:${repoFullName}#${prNumber}`
398
+ : headSha
399
+ ? `push:${repoFullName}:${headSha}`
400
+ : null;
401
+ if (!resourceRef) {
402
+ return null;
403
+ }
404
+ const conclusion = asString(item.metadata.conclusion);
405
+ const actionableFailure = Boolean(conclusion) && conclusion !== "success" && conclusion !== "skipped";
406
+ return {
407
+ groupable: true,
408
+ resourceRef,
409
+ eventFamily: "ci_updates",
410
+ summaryHint: prNumber != null
411
+ ? `CI updates for ${repoFullName}#${prNumber}`
412
+ : `CI updates for ${repoFullName}@${shortSha(headSha)}`,
413
+ flushClass: actionableFailure ? "immediate" : "normal",
414
+ flushDelayMs: githubCiDigestQuietWindowMs(config),
415
+ };
416
+ }
417
+ /**
418
+ * Post-hoc flush decision for a PR-level CI digest thread: flush once every
419
+ * workflow run observed for the thread's latest head sha has reached a
420
+ * terminal state (or the hard timeout expires). GitHub exposes no prior about
421
+ * how many runs a push will create, so readiness is judged from the observed
422
+ * event stream itself.
423
+ */
424
+ function decideGithubCiDigestFlush(items, config, context) {
425
+ if (items.length === 0) {
426
+ return { flush: true, reason: "no pending items" };
427
+ }
428
+ const ordered = orderItemsByOccurredAt(items);
429
+ const latestSha = latestHeadSha(ordered);
430
+ if (!latestSha) {
431
+ return { flush: true, reason: "no head sha" };
432
+ }
433
+ const nowMs = Date.parse(context.now);
434
+ const createdMs = Date.parse(context.threadCreatedAt);
435
+ const timeoutMs = githubCiDigestFlushTimeoutMs(config);
436
+ const timedOut = Number.isFinite(nowMs) && Number.isFinite(createdMs) && nowMs - createdMs >= timeoutMs;
437
+ if (!timedOut) {
438
+ const runTerminal = new Map();
439
+ for (const item of ordered) {
440
+ if (asString(item.metadata.headSha) !== latestSha) {
441
+ continue;
442
+ }
443
+ const runId = asNumber(item.metadata.workflowRunId);
444
+ if (!runId) {
445
+ continue;
446
+ }
447
+ const status = asString(item.metadata.status);
448
+ const conclusion = asString(item.metadata.conclusion);
449
+ // Sticky terminal: out-of-order delivery or timestamp skew must never
450
+ // downgrade a run that was already observed terminal back to pending,
451
+ // which would defer the flush until the hard timeout.
452
+ const observedTerminal = status === "completed" || Boolean(conclusion);
453
+ runTerminal.set(runId, (runTerminal.get(runId) ?? false) || observedTerminal);
454
+ }
455
+ let pending = 0;
456
+ for (const terminal of runTerminal.values()) {
457
+ if (!terminal) {
458
+ pending += 1;
459
+ }
460
+ }
461
+ if (pending > 0) {
462
+ const remaining = createdMs + timeoutMs - nowMs;
463
+ const recheckAfterMs = Number.isFinite(remaining)
464
+ ? Math.min(GITHUB_CI_DIGEST_RECHECK_MS, Math.max(GITHUB_CI_DIGEST_MIN_RECHECK_MS, remaining))
465
+ : GITHUB_CI_DIGEST_RECHECK_MS;
466
+ return {
467
+ flush: false,
468
+ reason: `${pending} run(s) still pending on ${shortSha(latestSha)}`,
469
+ recheckAfterMs,
470
+ };
471
+ }
472
+ }
473
+ return {
474
+ flush: true,
475
+ reason: timedOut ? `hard timeout on ${shortSha(latestSha)}` : `all runs terminal on ${shortSha(latestSha)}`,
476
+ };
477
+ }
478
+ function summarizeGithubCiDigestThread(items) {
479
+ if (items.length === 0) {
480
+ return null;
481
+ }
482
+ const ordered = orderItemsByOccurredAt(items);
483
+ let repoFullName = null;
484
+ let prNumber = null;
485
+ let latestSha = null;
486
+ for (const item of ordered) {
487
+ repoFullName = asString(item.metadata.repoFullName) ?? repoFullName;
488
+ prNumber = firstPullRequestNumber(item) ?? prNumber;
489
+ latestSha = asString(item.metadata.headSha) ?? latestSha;
490
+ }
491
+ const runs = new Map();
492
+ for (const item of ordered) {
493
+ const runId = asNumber(item.metadata.workflowRunId);
494
+ if (!runId) {
495
+ continue;
496
+ }
497
+ const previous = runs.get(runId) ?? { name: null, conclusion: null };
498
+ runs.set(runId, {
499
+ name: asString(item.metadata.name) ?? previous.name,
500
+ // Sticky conclusion: keep the first observed terminal conclusion so
501
+ // out-of-order items cannot rewrite a run's reported outcome.
502
+ conclusion: previous.conclusion ?? asString(item.metadata.conclusion),
503
+ });
504
+ }
505
+ const failed = [];
506
+ let passed = 0;
507
+ let running = 0;
508
+ for (const run of runs.values()) {
509
+ if (run.conclusion === "success" || run.conclusion === "skipped") {
510
+ passed += 1;
511
+ }
512
+ else if (run.conclusion) {
513
+ failed.push(run.name ? `${run.name} (${run.conclusion})` : run.conclusion);
514
+ }
515
+ else {
516
+ running += 1;
517
+ }
518
+ }
519
+ const target = repoFullName
520
+ ? prNumber != null
521
+ ? `${repoFullName}#${prNumber}`
522
+ : latestSha
523
+ ? `${repoFullName}@${shortSha(latestSha)}`
524
+ : repoFullName
525
+ : "repository";
526
+ const parts = [`${runs.size} workflow runs on ${shortSha(latestSha)}`];
527
+ if (failed.length > 0) {
528
+ const shown = failed.slice(0, 3).join(", ");
529
+ parts.push(`${failed.length} failed: ${shown}${failed.length > 3 ? ", …" : ""}`);
530
+ }
531
+ if (passed > 0) {
532
+ parts.push(`${passed} passed`);
533
+ }
534
+ if (running > 0) {
535
+ parts.push(`${running} still running`);
536
+ }
537
+ return `CI for ${target}: ${parts.join(", ")}`;
538
+ }
539
+ function orderItemsByOccurredAt(items) {
540
+ return [...items].sort((left, right) => Date.parse(left.occurredAt) - Date.parse(right.occurredAt));
541
+ }
542
+ function latestHeadSha(orderedItems) {
543
+ let latestSha = null;
544
+ let latestAt = Number.NaN;
545
+ for (const item of orderedItems) {
546
+ const sha = asString(item.metadata.headSha);
547
+ if (!sha) {
548
+ continue;
549
+ }
550
+ const at = Date.parse(item.occurredAt);
551
+ if (latestSha == null || (!Number.isNaN(at) && (Number.isNaN(latestAt) || at >= latestAt))) {
552
+ latestSha = sha;
553
+ latestAt = at;
554
+ }
555
+ }
556
+ return latestSha;
557
+ }
558
+ function shortSha(sha) {
559
+ return sha ? sha.slice(0, 7) : "unknown";
560
+ }
351
561
  function parseGithubCiCheckpoint(checkpoint) {
352
562
  if (!checkpoint) {
353
563
  return {};
@@ -299,6 +299,16 @@ class RemoteSourceRuntime {
299
299
  }
300
300
  return module.deriveNotificationGrouping(item, moduleInputSource(source));
301
301
  }
302
+ async shouldFlushDigestThread(source, items, context) {
303
+ if (!REMOTE_SOURCE_TYPES.has(source.sourceType)) {
304
+ return null;
305
+ }
306
+ const module = await this.moduleRegistry.resolve(source, this.homeDir);
307
+ if (typeof module.shouldFlushDigestThread !== "function") {
308
+ return null;
309
+ }
310
+ return module.shouldFlushDigestThread(items, moduleInputSource(source), context);
311
+ }
302
312
  async summarizeDigestThread(source, items, grouping) {
303
313
  if (!REMOTE_SOURCE_TYPES.has(source.sourceType)) {
304
314
  return null;
@@ -123,6 +123,7 @@ function validateModuleContract(module, sourcePath) {
123
123
  validateOptionalHook(module.projectLifecycleSignal, "projectLifecycleSignal", sourcePath);
124
124
  validateOptionalHook(module.deriveInlinePreview, "deriveInlinePreview", sourcePath);
125
125
  validateOptionalHook(module.deriveNotificationGrouping, "deriveNotificationGrouping", sourcePath);
126
+ validateOptionalHook(module.shouldFlushDigestThread, "shouldFlushDigestThread", sourcePath);
126
127
  validateOptionalHook(module.listDeliveryOperations, "listDeliveryOperations", sourcePath);
127
128
  validateOptionalHook(module.invokeDeliveryOperation, "invokeDeliveryOperation", sourcePath);
128
129
  validateOptionalHook(module.listSourceOperations, "listSourceOperations", sourcePath);
@@ -401,26 +402,18 @@ const GITHUB_REPO_CI_MODULE = {
401
402
  }
402
403
  return parts.join(" ");
403
404
  },
404
- deriveNotificationGrouping(item) {
405
- const workflowRunId = typeof item.metadata.workflowRunId === "number" ? item.metadata.workflowRunId : null;
406
- if (!workflowRunId) {
407
- return null;
408
- }
409
- const conclusion = asNonEmptyString(item.metadata.conclusion);
410
- const status = asNonEmptyString(item.metadata.status);
411
- return {
412
- groupable: true,
413
- resourceRef: `workflow_run:${workflowRunId}`,
414
- eventFamily: "ci_updates",
415
- summaryHint: `CI updates for run #${workflowRunId}`,
416
- flushClass: conclusion || status === "completed" ? "immediate" : "normal",
417
- };
405
+ deriveNotificationGrouping(item, source) {
406
+ return (0, github_ci_1.deriveGithubCiNotificationGrouping)(item, (0, github_ci_1.parseGithubCiSourceConfig)(source));
407
+ },
408
+ shouldFlushDigestThread(items, source, context) {
409
+ return (0, github_ci_1.decideGithubCiDigestFlush)(items, (0, github_ci_1.parseGithubCiSourceConfig)(source), context);
418
410
  },
419
411
  summarizeDigestThread(items, _source, grouping) {
420
- if (!grouping.summaryHint || items.length === 0) {
412
+ if (items.length === 0) {
421
413
  return null;
422
414
  }
423
- return `${items.length} ${grouping.summaryHint}`;
415
+ return (0, github_ci_1.summarizeGithubCiDigestThread)(items)
416
+ ?? (grouping.summaryHint ? `${items.length} ${grouping.summaryHint}` : null);
424
417
  },
425
418
  validateConfig(source) {
426
419
  (0, github_ci_1.parseGithubCiSourceConfig)(source);
package/dist/src/store.js CHANGED
@@ -1710,6 +1710,14 @@ class AgentInboxStore {
1710
1710
  const rows = this.getAll("select * from digest_threads where status = 'open' order by created_at asc, thread_id asc");
1711
1711
  return rows.map((row) => this.mapDigestThread(row));
1712
1712
  }
1713
+ updateDigestThreadFlushAfterAt(threadId, flushAfterAt) {
1714
+ this.run(`
1715
+ update digest_threads
1716
+ set flush_after_at = ?, updated_at = ?
1717
+ where thread_id = ? and status = 'open'
1718
+ `, [flushAfterAt, (0, util_1.nowIso)(), threadId]);
1719
+ this.persist();
1720
+ }
1713
1721
  materializeDigestSnapshot(input) {
1714
1722
  const thread = this.getDigestThread(input.threadId);
1715
1723
  if (!thread) {
@@ -0,0 +1,8 @@
1
+ -- Aggregation is now enabled by default for agent inboxes: bursty events on
2
+ -- the same resource collapse into one digest notification instead of waking
3
+ -- the agent once per event. Existing inboxes were left disabled by the old
4
+ -- default, so flip them on in bulk; explicit opt-outs can set the flag back
5
+ -- to 0 per inbox via the inbox aggregation policy.
6
+ update inboxes
7
+ set aggregation_enabled = 1
8
+ where aggregation_enabled = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Local event subscription and delivery service for agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {