metheus-governance-mcp-cli 0.2.270 → 0.2.272

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/cli.mjs CHANGED
@@ -2669,39 +2669,47 @@ function saveBotRunnerState(nextState) {
2669
2669
  });
2670
2670
  }
2671
2671
 
2672
- function normalizeBotRunnerExcludedComments(rawExcluded, nowMs = Date.now()) {
2673
- const normalized = {};
2674
- for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawExcluded))) {
2675
- const commentID = String(commentIDRaw || "").trim();
2676
- if (!commentID) continue;
2672
+ function normalizeBotRunnerExcludedComments(rawExcluded, nowMs = Date.now()) {
2673
+ const normalized = {};
2674
+ for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawExcluded))) {
2675
+ const commentID = String(commentIDRaw || "").trim();
2676
+ if (!commentID) continue;
2677
2677
  const entry = safeObject(entryRaw);
2678
2678
  const excludedAt = firstNonEmptyString([entry.excluded_at, entry.excludedAt, entry.updated_at, entry.updatedAt]);
2679
2679
  const excludedAtMs = Date.parse(excludedAt);
2680
2680
  if (Number.isFinite(excludedAtMs) && nowMs - excludedAtMs > BOT_RUNNER_EXCLUDED_COMMENT_KEEP_MS) {
2681
2681
  continue;
2682
2682
  }
2683
- normalized[commentID] = {
2684
- comment_id: commentID,
2685
- project_id: String(entry.project_id || entry.projectID || "").trim(),
2686
- provider: String(entry.provider || "").trim(),
2687
- request_key: String(entry.request_key || entry.requestKey || "").trim(),
2688
- route_key: String(entry.route_key || entry.routeKey || "").trim(),
2689
- excluded_at: excludedAt || new Date(nowMs).toISOString(),
2690
- reason_code: String(entry.reason_code || entry.reasonCode || entry.reason || "").trim(),
2691
- decision: String(entry.decision || "").trim().toLowerCase(),
2692
- action: String(entry.action || "").trim().toLowerCase(),
2693
- conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
2694
- source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
2695
- context_excluded: entry.context_excluded !== false,
2696
- closed_reason: String(entry.closed_reason || entry.closedReason || "").trim(),
2697
- };
2698
- }
2699
- return normalized;
2683
+ normalized[commentID] = {
2684
+ comment_id: commentID,
2685
+ project_id: String(entry.project_id || entry.projectID || "").trim(),
2686
+ provider: String(entry.provider || "").trim(),
2687
+ request_key: String(entry.request_key || entry.requestKey || "").trim(),
2688
+ route_key: String(entry.route_key || entry.routeKey || "").trim(),
2689
+ excluded_at: excludedAt || new Date(nowMs).toISOString(),
2690
+ reason_code: String(entry.reason_code || entry.reasonCode || entry.reason || "").trim(),
2691
+ decision: String(entry.decision || "").trim().toLowerCase(),
2692
+ action: String(entry.action || "").trim().toLowerCase(),
2693
+ conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
2694
+ source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
2695
+ comment_kind: String(entry.comment_kind || entry.commentKind || "").trim().toLowerCase(),
2696
+ request_status: normalizeRunnerRequestStatus(entry.request_status || entry.requestStatus),
2697
+ source_occurred_at: String(entry.source_occurred_at || entry.sourceOccurredAt || "").trim(),
2698
+ stale_after_at: String(entry.stale_after_at || entry.staleAfterAt || "").trim(),
2699
+ selection_state: normalizeRunnerCommentSelectionState(
2700
+ entry.selection_state || entry.selectionState,
2701
+ "superseded",
2702
+ ),
2703
+ context_excluded: entry.context_excluded !== false,
2704
+ closed_reason: String(entry.closed_reason || entry.closedReason || "").trim(),
2705
+ };
2706
+ }
2707
+ return normalized;
2700
2708
  }
2701
2709
 
2702
- function normalizeRunnerRequestStatus(rawStatus) {
2703
- const status = String(rawStatus || "").trim().toLowerCase();
2704
- return [
2710
+ function normalizeRunnerRequestStatus(rawStatus) {
2711
+ const status = String(rawStatus || "").trim().toLowerCase();
2712
+ return [
2705
2713
  "planned",
2706
2714
  "claimed",
2707
2715
  "running",
@@ -2710,9 +2718,54 @@ function normalizeRunnerRequestStatus(rawStatus) {
2710
2718
  "expired",
2711
2719
  "loop_closed",
2712
2720
  ].includes(status)
2713
- ? status
2714
- : "planned";
2715
- }
2721
+ ? status
2722
+ : "planned";
2723
+ }
2724
+
2725
+ function normalizeRunnerCommentSelectionState(rawState, fallback = "pending") {
2726
+ const normalizedFallback = String(fallback || "").trim().toLowerCase() || "pending";
2727
+ const normalized = String(rawState || "").trim().toLowerCase();
2728
+ return [
2729
+ "pending",
2730
+ "consumed",
2731
+ "closed",
2732
+ "superseded",
2733
+ "stale",
2734
+ ].includes(normalized)
2735
+ ? normalized
2736
+ : normalizedFallback;
2737
+ }
2738
+
2739
+ function resolveRunnerCommentStateSourceOccurredAt(recordRaw) {
2740
+ const record = safeObject(recordRaw);
2741
+ const parsed = safeObject(record.parsedArchive);
2742
+ return firstNonEmptyString([
2743
+ record.source_occurred_at,
2744
+ record.sourceOccurredAt,
2745
+ parsed.occurredAt,
2746
+ parsed.occurred_at,
2747
+ record.created_at,
2748
+ record.createdAt,
2749
+ record.updated_at,
2750
+ record.updatedAt,
2751
+ ]);
2752
+ }
2753
+
2754
+ function resolveRunnerCommentStateStaleAfterAt(recordRaw, maxAgeMs = BOT_RUNNER_PENDING_COMMENT_MAX_AGE_MS) {
2755
+ const explicit = firstNonEmptyString([
2756
+ safeObject(recordRaw).stale_after_at,
2757
+ safeObject(recordRaw).staleAfterAt,
2758
+ ]);
2759
+ if (explicit) {
2760
+ return explicit;
2761
+ }
2762
+ const sourceOccurredAt = resolveRunnerCommentStateSourceOccurredAt(recordRaw);
2763
+ const sourceOccurredAtMs = Date.parse(sourceOccurredAt);
2764
+ if (!Number.isFinite(sourceOccurredAtMs) || !(maxAgeMs > 0)) {
2765
+ return "";
2766
+ }
2767
+ return new Date(sourceOccurredAtMs + maxAgeMs).toISOString();
2768
+ }
2716
2769
 
2717
2770
  function isFinalRunnerRequestStatus(rawStatus) {
2718
2771
  const status = normalizeRunnerRequestStatus(rawStatus);
@@ -3245,9 +3298,9 @@ function normalizeBotRunnerRequests(rawRequests, nowMs = Date.now()) {
3245
3298
  return normalized;
3246
3299
  }
3247
3300
 
3248
- function normalizeBotRunnerConsumedComments(rawConsumed, nowMs = Date.now()) {
3249
- const normalized = {};
3250
- for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawConsumed))) {
3301
+ function normalizeBotRunnerConsumedComments(rawConsumed, nowMs = Date.now()) {
3302
+ const normalized = {};
3303
+ for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawConsumed))) {
3251
3304
  const entry = safeObject(entryRaw);
3252
3305
  const commentID = String(entry.comment_id || commentIDRaw || "").trim();
3253
3306
  if (!commentID) continue;
@@ -3257,21 +3310,27 @@ function normalizeBotRunnerConsumedComments(rawConsumed, nowMs = Date.now()) {
3257
3310
  if (Number.isFinite(consumedAtMs) && nowMs - consumedAtMs > BOT_RUNNER_REQUEST_KEEP_MS) {
3258
3311
  continue;
3259
3312
  }
3260
- normalized[ledgerKey] = {
3261
- ledger_key: ledgerKey,
3262
- comment_id: commentID,
3263
- project_id: String(entry.project_id || entry.projectID || "").trim(),
3264
- provider: String(entry.provider || "").trim(),
3265
- request_key: String(entry.request_key || entry.requestKey || "").trim(),
3266
- consumed_at: consumedAt || new Date(nowMs).toISOString(),
3267
- route_key: String(entry.route_key || entry.routeKey || "").trim(),
3268
- conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
3269
- source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
3270
- comment_kind: String(entry.comment_kind || entry.commentKind || "").trim().toLowerCase(),
3271
- request_status: normalizeRunnerRequestStatus(entry.request_status || entry.requestStatus),
3272
- };
3273
- }
3274
- return normalized;
3313
+ normalized[ledgerKey] = {
3314
+ ledger_key: ledgerKey,
3315
+ comment_id: commentID,
3316
+ project_id: String(entry.project_id || entry.projectID || "").trim(),
3317
+ provider: String(entry.provider || "").trim(),
3318
+ request_key: String(entry.request_key || entry.requestKey || "").trim(),
3319
+ consumed_at: consumedAt || new Date(nowMs).toISOString(),
3320
+ route_key: String(entry.route_key || entry.routeKey || "").trim(),
3321
+ conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
3322
+ source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
3323
+ source_occurred_at: String(entry.source_occurred_at || entry.sourceOccurredAt || "").trim(),
3324
+ stale_after_at: String(entry.stale_after_at || entry.staleAfterAt || "").trim(),
3325
+ selection_state: normalizeRunnerCommentSelectionState(
3326
+ entry.selection_state || entry.selectionState,
3327
+ "consumed",
3328
+ ),
3329
+ comment_kind: String(entry.comment_kind || entry.commentKind || "").trim().toLowerCase(),
3330
+ request_status: normalizeRunnerRequestStatus(entry.request_status || entry.requestStatus),
3331
+ };
3332
+ }
3333
+ return normalized;
3275
3334
  }
3276
3335
 
3277
3336
  function buildRunnerConsumedCommentLedgerKey(commentIDRaw, routeKeyRaw = "", commentKindRaw = "") {
@@ -4788,10 +4847,10 @@ function upsertRunnerRequest(state, requestKey, patch = {}) {
4788
4847
  };
4789
4848
  }
4790
4849
 
4791
- function upsertRunnerConsumedComment(state, commentIDRaw, patch = {}) {
4792
- const commentID = String(commentIDRaw || "").trim();
4793
- const currentState = safeObject(state);
4794
- const consumedComments = normalizeBotRunnerConsumedComments(currentState.consumedComments || currentState.consumed_comments);
4850
+ function upsertRunnerConsumedComment(state, commentIDRaw, patch = {}) {
4851
+ const commentID = String(commentIDRaw || "").trim();
4852
+ const currentState = safeObject(state);
4853
+ const consumedComments = normalizeBotRunnerConsumedComments(currentState.consumedComments || currentState.consumed_comments);
4795
4854
  const ledgerKey = buildRunnerConsumedCommentLedgerKey(commentID, patch.route_key, patch.comment_kind);
4796
4855
  const existing = safeObject(consumedComments[ledgerKey]);
4797
4856
  const nextEntry = {
@@ -4799,10 +4858,22 @@ function upsertRunnerConsumedComment(state, commentIDRaw, patch = {}) {
4799
4858
  ...safeObject(patch),
4800
4859
  comment_id: commentID,
4801
4860
  project_id: String(patch.project_id || existing.project_id || "").trim(),
4802
- provider: String(patch.provider || existing.provider || "").trim(),
4803
- consumed_at: new Date().toISOString(),
4804
- request_status: normalizeRunnerRequestStatus(patch.request_status || existing.request_status),
4805
- };
4861
+ provider: String(patch.provider || existing.provider || "").trim(),
4862
+ consumed_at: new Date().toISOString(),
4863
+ source_occurred_at: firstNonEmptyString([
4864
+ patch.source_occurred_at,
4865
+ existing.source_occurred_at,
4866
+ ]),
4867
+ stale_after_at: firstNonEmptyString([
4868
+ patch.stale_after_at,
4869
+ existing.stale_after_at,
4870
+ ]),
4871
+ selection_state: normalizeRunnerCommentSelectionState(
4872
+ patch.selection_state || existing.selection_state,
4873
+ "consumed",
4874
+ ),
4875
+ request_status: normalizeRunnerRequestStatus(patch.request_status || existing.request_status),
4876
+ };
4806
4877
  consumedComments[ledgerKey] = nextEntry;
4807
4878
  return {
4808
4879
  consumedComments,
@@ -5128,16 +5199,19 @@ async function claimRunnerRequestForHumanComment({
5128
5199
  last_source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
5129
5200
  last_source_message_thread_id: intFromRawAllowZero(parsed.messageThreadID, 0) || undefined,
5130
5201
  });
5131
- const { consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(stateForClaim, selectedRecord?.id, {
5132
- project_id: String(normalizedRoute?.projectID || "").trim(),
5133
- provider: String(normalizedRoute?.provider || "").trim(),
5134
- request_key: requestKey,
5135
- route_key: String(routeKey || "").trim(),
5136
- conversation_id: resolvedConversationID,
5137
- source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
5138
- comment_kind: commentKind,
5139
- request_status: "claimed",
5140
- });
5202
+ const { consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(stateForClaim, selectedRecord?.id, {
5203
+ project_id: String(normalizedRoute?.projectID || "").trim(),
5204
+ provider: String(normalizedRoute?.provider || "").trim(),
5205
+ request_key: requestKey,
5206
+ route_key: String(routeKey || "").trim(),
5207
+ conversation_id: resolvedConversationID,
5208
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
5209
+ source_occurred_at: resolveRunnerCommentStateSourceOccurredAt(selectedRecord),
5210
+ stale_after_at: resolveRunnerCommentStateStaleAfterAt(selectedRecord),
5211
+ selection_state: "consumed",
5212
+ comment_kind: commentKind,
5213
+ request_status: "claimed",
5214
+ });
5141
5215
  saveBotRunnerState({
5142
5216
  routes: stateForClaim.routes,
5143
5217
  sharedInboxes: stateForClaim.sharedInboxes || stateForClaim.shared_inboxes,
@@ -6561,17 +6635,20 @@ function markRunnerRequestLifecycle({
6561
6635
  const commentID = String(selectedRecord?.id || "").trim();
6562
6636
  let nextConsumedComments = currentState.consumedComments || currentState.consumed_comments;
6563
6637
  if (commentID) {
6564
- ({ consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(currentState, commentID, {
6565
- project_id: String(normalizedRoute?.projectID || "").trim(),
6566
- provider: String(normalizedRoute?.provider || "").trim(),
6567
- request_key: key,
6568
- route_key: String(routeKey || "").trim(),
6569
- conversation_id: conversationID,
6570
- source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
6571
- comment_kind: String(parsed.kind || "").trim().toLowerCase(),
6572
- request_status: nextStatus,
6573
- }));
6574
- }
6638
+ ({ consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(currentState, commentID, {
6639
+ project_id: String(normalizedRoute?.projectID || "").trim(),
6640
+ provider: String(normalizedRoute?.provider || "").trim(),
6641
+ request_key: key,
6642
+ route_key: String(routeKey || "").trim(),
6643
+ conversation_id: conversationID,
6644
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
6645
+ source_occurred_at: resolveRunnerCommentStateSourceOccurredAt(selectedRecord),
6646
+ stale_after_at: resolveRunnerCommentStateStaleAfterAt(selectedRecord),
6647
+ selection_state: isFinalRunnerRequestStatus(nextStatus) ? "closed" : "consumed",
6648
+ comment_kind: String(parsed.kind || "").trim().toLowerCase(),
6649
+ request_status: nextStatus,
6650
+ }));
6651
+ }
6575
6652
  saveBotRunnerState({
6576
6653
  routes: currentState.routes,
6577
6654
  sharedInboxes: currentState.sharedInboxes || currentState.shared_inboxes,
@@ -6900,16 +6977,22 @@ function mergeServerRunnerRequestLedgerIntoLocalState(currentState, normalizedRo
6900
6977
  const commentState = safeObject(commentStateRaw);
6901
6978
  const commentID = String(commentState.comment_id || commentState.commentID || "").trim();
6902
6979
  if (!commentID) continue;
6903
- const normalizedCommentState = normalizeBotRunnerConsumedComments({ [commentID]: commentState })[commentID];
6904
- if (normalizedCommentState) {
6905
- nextConsumedComments[commentID] = normalizedCommentState;
6906
- }
6907
- if (commentState.context_excluded === true) {
6908
- nextExcludedComments[commentID] = normalizeBotRunnerExcludedComments({
6909
- [commentID]: {
6910
- ...commentState,
6911
- excluded_at: commentState.updated_at || commentState.consumed_at,
6912
- },
6980
+ const normalizedCommentState = normalizeBotRunnerConsumedComments({ [commentID]: commentState })[commentID];
6981
+ if (normalizedCommentState) {
6982
+ nextConsumedComments[commentID] = normalizedCommentState;
6983
+ }
6984
+ const normalizedSelectionState = normalizeRunnerCommentSelectionState(commentState.selection_state, "pending");
6985
+ if (
6986
+ commentState.context_excluded === true
6987
+ || normalizedSelectionState === "stale"
6988
+ || normalizedSelectionState === "superseded"
6989
+ || normalizedSelectionState === "closed"
6990
+ ) {
6991
+ nextExcludedComments[commentID] = normalizeBotRunnerExcludedComments({
6992
+ [commentID]: {
6993
+ ...commentState,
6994
+ excluded_at: commentState.updated_at || commentState.consumed_at,
6995
+ },
6913
6996
  })[commentID];
6914
6997
  }
6915
6998
  }
@@ -6967,24 +7050,27 @@ function buildProjectRunnerRequestCommentStatesForSync(state, normalizedRoute) {
6967
7050
  if (!runnerLedgerEntryMatchesProject(entry, normalizedRoute, requestIndex)) continue;
6968
7051
  const commentID = String(entry.comment_id || "").trim();
6969
7052
  if (!commentID) continue;
6970
- commentStateMap.set(commentID, {
6971
- comment_id: commentID,
6972
- project_id: projectID,
6973
- request_key: String(entry.request_key || "").trim(),
6974
- provider: String(entry.provider || provider).trim(),
7053
+ commentStateMap.set(commentID, {
7054
+ comment_id: commentID,
7055
+ project_id: projectID,
7056
+ request_key: String(entry.request_key || "").trim(),
7057
+ provider: String(entry.provider || provider).trim(),
6975
7058
  route_key: String(entry.route_key || "").trim(),
6976
7059
  conversation_id: String(entry.conversation_id || "").trim(),
6977
7060
  source_message_id: intFromRawAllowZero(entry.source_message_id, 0) || undefined,
6978
7061
  comment_kind: String(entry.comment_kind || "").trim(),
6979
7062
  request_status: String(entry.request_status || "").trim(),
6980
- reason_code: "",
6981
- decision: "",
6982
- action: "",
6983
- context_excluded: false,
6984
- closed_reason: "",
6985
- consumed_at: String(entry.consumed_at || "").trim(),
6986
- });
6987
- }
7063
+ reason_code: "",
7064
+ decision: "",
7065
+ action: "",
7066
+ context_excluded: false,
7067
+ closed_reason: "",
7068
+ source_occurred_at: String(entry.source_occurred_at || "").trim(),
7069
+ stale_after_at: String(entry.stale_after_at || "").trim(),
7070
+ selection_state: normalizeRunnerCommentSelectionState(entry.selection_state, "consumed"),
7071
+ consumed_at: String(entry.consumed_at || "").trim(),
7072
+ });
7073
+ }
6988
7074
 
6989
7075
  for (const entryRaw of Object.values(excludedComments)) {
6990
7076
  const entry = safeObject(entryRaw);
@@ -7004,13 +7090,19 @@ function buildProjectRunnerRequestCommentStatesForSync(state, normalizedRoute) {
7004
7090
  comment_kind: String(entry.comment_kind || existing.comment_kind || "").trim(),
7005
7091
  request_status: String(entry.request_status || existing.request_status || "").trim(),
7006
7092
  reason_code: String(entry.reason_code || "").trim(),
7007
- decision: String(entry.decision || "").trim(),
7008
- action: String(entry.action || "").trim(),
7009
- context_excluded: entry.context_excluded !== false,
7010
- closed_reason: String(entry.closed_reason || existing.closed_reason || "").trim(),
7011
- consumed_at: String(existing.consumed_at || entry.excluded_at || "").trim(),
7012
- });
7013
- }
7093
+ decision: String(entry.decision || "").trim(),
7094
+ action: String(entry.action || "").trim(),
7095
+ context_excluded: entry.context_excluded !== false,
7096
+ closed_reason: String(entry.closed_reason || existing.closed_reason || "").trim(),
7097
+ source_occurred_at: String(entry.source_occurred_at || existing.source_occurred_at || "").trim(),
7098
+ stale_after_at: String(entry.stale_after_at || existing.stale_after_at || "").trim(),
7099
+ selection_state: normalizeRunnerCommentSelectionState(
7100
+ entry.selection_state || existing.selection_state,
7101
+ entry.context_excluded !== false ? "superseded" : "pending",
7102
+ ),
7103
+ consumed_at: String(existing.consumed_at || entry.excluded_at || "").trim(),
7104
+ });
7105
+ }
7014
7106
 
7015
7107
  return Array.from(commentStateMap.values());
7016
7108
  }
@@ -10258,10 +10350,10 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
10258
10350
  }
10259
10351
  return true;
10260
10352
  });
10261
- const pendingWork = selectRunnerPendingWork({
10262
- comments: commentsForPending,
10263
- importOutcome,
10264
- refreshedState,
10353
+ const pendingWork = selectRunnerPendingWork({
10354
+ comments: commentsForPending,
10355
+ importOutcome,
10356
+ refreshedState,
10265
10357
  mode,
10266
10358
  parseArchivedChatComment,
10267
10359
  pendingSelectionOptions: {
@@ -10271,13 +10363,52 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
10271
10363
  applyPendingAgeSelection,
10272
10364
  normalizeArchiveCommentRecord,
10273
10365
  },
10274
- });
10275
- const pending = pendingWork.pending;
10276
- if (pending.staleSkippedLatest) {
10277
- saveRunnerRouteState(routeKey, buildRunnerRouteStateFromComment(pending.staleSkippedLatest, {
10278
- last_reason: `skipped ${ensureArray(pending.staleSkipped).length} stale archive message(s) older than ${Math.floor(BOT_RUNNER_PENDING_COMMENT_MAX_AGE_MS / 60000)} minutes`,
10279
- }));
10280
- refreshedState = safeObject(loadBotRunnerState().routes[routeKey]);
10366
+ });
10367
+ const pending = pendingWork.pending;
10368
+ if (pending.staleSkippedLatest) {
10369
+ let staleStateChanged = false;
10370
+ for (const staleRecordRaw of ensureArray(pending.staleSkipped)) {
10371
+ const staleRecord = safeObject(staleRecordRaw);
10372
+ const commentID = String(staleRecord.id || "").trim();
10373
+ if (!commentID) continue;
10374
+ const parsed = safeObject(staleRecord.parsedArchive);
10375
+ const existingExcludedEntry = safeObject(excludedComments[commentID]);
10376
+ const stalePatch = {
10377
+ project_id: String(normalizedRoute.projectID || "").trim(),
10378
+ provider: String(normalizedRoute.provider || "").trim(),
10379
+ route_key: routeKey,
10380
+ request_key: String(existingExcludedEntry.request_key || "").trim(),
10381
+ conversation_id: String(parsed.conversationID || parsed.conversationId || existingExcludedEntry.conversation_id || "").trim(),
10382
+ source_message_id: intFromRawAllowZero(parsed.messageID || existingExcludedEntry.source_message_id, 0) || undefined,
10383
+ comment_kind: String(parsed.kind || existingExcludedEntry.comment_kind || "").trim().toLowerCase(),
10384
+ request_status: "expired",
10385
+ source_occurred_at: resolveRunnerCommentStateSourceOccurredAt(staleRecord),
10386
+ stale_after_at: resolveRunnerCommentStateStaleAfterAt(staleRecord),
10387
+ selection_state: "stale",
10388
+ reason_code: "stale_archive_message",
10389
+ decision: "skip",
10390
+ action: "stale_skipped",
10391
+ context_excluded: true,
10392
+ closed_reason: "stale_source_message",
10393
+ };
10394
+ const updatedExcludedEntry = markBotRunnerExcludedComment(commentID, stalePatch);
10395
+ if (
10396
+ normalizeRunnerCommentSelectionState(updatedExcludedEntry.selection_state, "superseded") === "stale"
10397
+ && String(updatedExcludedEntry.closed_reason || "").trim() === "stale_source_message"
10398
+ ) {
10399
+ staleStateChanged = true;
10400
+ }
10401
+ }
10402
+ if (staleStateChanged) {
10403
+ await syncRunnerRequestLedgerForProjectToServer({
10404
+ normalizedRoute,
10405
+ runtime,
10406
+ });
10407
+ }
10408
+ saveRunnerRouteState(routeKey, buildRunnerRouteStateFromComment(pending.staleSkippedLatest, {
10409
+ last_reason: `skipped ${ensureArray(pending.staleSkipped).length} stale archive message(s) older than ${Math.floor(BOT_RUNNER_PENDING_COMMENT_MAX_AGE_MS / 60000)} minutes`,
10410
+ }));
10411
+ refreshedState = safeObject(loadBotRunnerState().routes[routeKey]);
10281
10412
  }
10282
10413
  if (pending.shouldPrime && pending.latest) {
10283
10414
  saveRunnerRouteState(routeKey, buildRunnerRouteStateFromComment(pending.latest, { primed: true }));
@@ -478,6 +478,9 @@ export async function listProjectRunnerRequestCommentStates(
478
478
  action: String(row.action || row.Action || "").trim(),
479
479
  context_excluded: row.context_excluded === true || row.ContextExcluded === true,
480
480
  closed_reason: String(row.closed_reason || row.closedReason || row.ClosedReason || "").trim(),
481
+ source_occurred_at: String(row.source_occurred_at || row.sourceOccurredAt || row.SourceOccurredAt || "").trim(),
482
+ stale_after_at: String(row.stale_after_at || row.staleAfterAt || row.StaleAfterAt || "").trim(),
483
+ selection_state: String(row.selection_state || row.selectionState || row.SelectionState || "").trim(),
481
484
  consumed_at: String(row.consumed_at || row.consumedAt || row.ConsumedAt || "").trim(),
482
485
  updated_at: String(row.updated_at || row.updatedAt || row.UpdatedAt || "").trim(),
483
486
  };
@@ -513,6 +516,9 @@ export async function upsertProjectRunnerRequestCommentState(
513
516
  action: String(raw.action || "").trim(),
514
517
  context_excluded: raw.context_excluded === true,
515
518
  closed_reason: String(raw.closed_reason || "").trim(),
519
+ source_occurred_at: String(raw.source_occurred_at || "").trim() || undefined,
520
+ stale_after_at: String(raw.stale_after_at || "").trim() || undefined,
521
+ selection_state: String(raw.selection_state || "").trim() || undefined,
516
522
  consumed_at: String(raw.consumed_at || "").trim() || undefined,
517
523
  };
518
524
  const extraHeaders = actorUserID ? { "X-Actor-User-Id": actorUserID } : {};
@@ -254,12 +254,28 @@ function normalizePendingSelectionOptions(rawOptions) {
254
254
  };
255
255
  }
256
256
 
257
+ function resolveArchiveRecordEventTime(record) {
258
+ const normalizedRecord = safeObject(record);
259
+ const parsedArchive = safeObject(normalizedRecord.parsedArchive);
260
+ return firstNonEmptyString([
261
+ normalizedRecord.sourceOccurredAt,
262
+ parsedArchive.occurredAt,
263
+ parsedArchive.occurred_at,
264
+ normalizedRecord.createdAt,
265
+ normalizedRecord.updatedAt,
266
+ ]);
267
+ }
268
+
257
269
  function isArchiveRecordWithinPendingAgeLimit(record, rawOptions) {
258
270
  const options = normalizePendingSelectionOptions(rawOptions);
259
271
  if (!(options.maxPendingAgeMs > 0)) {
260
272
  return true;
261
273
  }
262
- const recordTime = Date.parse(firstNonEmptyString([record?.createdAt, record?.updatedAt]));
274
+ const staleAfterTime = Date.parse(String(safeObject(record).staleAfterAt || ""));
275
+ if (Number.isFinite(staleAfterTime)) {
276
+ return options.nowMs <= staleAfterTime;
277
+ }
278
+ const recordTime = Date.parse(resolveArchiveRecordEventTime(record));
263
279
  if (!Number.isFinite(recordTime)) {
264
280
  return true;
265
281
  }
@@ -411,8 +427,8 @@ function contextRelatednessScore(record, selectedRecord, rawOptions) {
411
427
  }
412
428
 
413
429
  export function compareArchiveCommentRecords(left, right) {
414
- const leftTime = firstNonEmptyString([left.createdAt, left.updatedAt]);
415
- const rightTime = firstNonEmptyString([right.createdAt, right.updatedAt]);
430
+ const leftTime = resolveArchiveRecordEventTime(left);
431
+ const rightTime = resolveArchiveRecordEventTime(right);
416
432
  if (leftTime && rightTime && leftTime !== rightTime) {
417
433
  return leftTime < rightTime ? -1 : 1;
418
434
  }
@@ -521,13 +537,25 @@ export function buildRunnerRouteStateFromComment(record, patch = {}) {
521
537
  export function normalizeArchiveCommentRecord(rawComment, parseArchivedChatComment) {
522
538
  const comment = safeObject(rawComment);
523
539
  const body = String(comment.body || "").trim();
540
+ const parsedArchive = typeof parseArchivedChatComment === "function" ? parseArchivedChatComment(body) : null;
524
541
  return {
525
542
  id: String(comment.id || "").trim(),
526
543
  body,
527
544
  createdAt: firstNonEmptyString([comment.created_at, comment.createdAt, comment.updated_at, comment.updatedAt]),
528
545
  updatedAt: firstNonEmptyString([comment.updated_at, comment.updatedAt]),
529
546
  authorUserID: firstNonEmptyString([comment.author_user_id, comment.authorUserId, comment.created_by]),
530
- parsedArchive: typeof parseArchivedChatComment === "function" ? parseArchivedChatComment(body) : null,
547
+ sourceOccurredAt: firstNonEmptyString([
548
+ comment.source_occurred_at,
549
+ comment.sourceOccurredAt,
550
+ safeObject(parsedArchive).occurredAt,
551
+ safeObject(parsedArchive).occurred_at,
552
+ ]),
553
+ staleAfterAt: firstNonEmptyString([
554
+ comment.stale_after_at,
555
+ comment.staleAfterAt,
556
+ ]),
557
+ selectionState: String(comment.selection_state || comment.selectionState || "").trim().toLowerCase(),
558
+ parsedArchive,
531
559
  };
532
560
  }
533
561
 
@@ -51,6 +51,48 @@ function detectDirectedManagedReplyTarget({
51
51
  return "";
52
52
  }
53
53
 
54
+ const DIRECTED_MANAGED_REPLY_ENGLISH_ACTION_PATTERN = "\\b(?:say|tell|greet|reply|answer|introduce|mention)\\b";
55
+ const DIRECTED_MANAGED_REPLY_KOREAN_ACTION_PATTERN = "(?:\\uC778\\uC0AC|\\uB9D0|\\uC804\\uB2EC|\\uB2F5|\\uC18C\\uAC1C|\\uC5B8\\uAE09)";
56
+ const DIRECTED_MANAGED_REPLY_ACTION_PATTERN = `(?:${DIRECTED_MANAGED_REPLY_ENGLISH_ACTION_PATTERN}|${DIRECTED_MANAGED_REPLY_KOREAN_ACTION_PATTERN})`;
57
+ const DIRECTED_MANAGED_REPLY_POSTPOSITION_PATTERN = "(?:\\uC5D0\\uAC8C|\\uD55C\\uD14C|\\uAED8|\\uBCF4\\uACE0)";
58
+
59
+ function detectDirectedManagedReplyTargetV2({
60
+ text,
61
+ currentBotSelector = "",
62
+ managedMentions = [],
63
+ }) {
64
+ const normalizedText = String(text || "").trim();
65
+ const currentSelector = String(currentBotSelector || "").trim().toLowerCase();
66
+ if (!normalizedText || !currentSelector) {
67
+ return "";
68
+ }
69
+ const instructionPattern = new RegExp(DIRECTED_MANAGED_REPLY_ACTION_PATTERN, "iu");
70
+ if (!instructionPattern.test(normalizedText)) {
71
+ return "";
72
+ }
73
+ const candidates = ensureArray(managedMentions)
74
+ .map((item) => String(item || "").trim().toLowerCase())
75
+ .filter((item) => item && item !== currentSelector);
76
+ for (const selector of candidates) {
77
+ const escapedSelector = escapeRegexText(String(selector || "").replace(/^@+/, ""));
78
+ const explicitTargetPattern = new RegExp(
79
+ `(?:@${escapedSelector}\\s*(?:${DIRECTED_MANAGED_REPLY_POSTPOSITION_PATTERN})?|(?:to|for)\\s+@${escapedSelector}\\b)`,
80
+ "iu",
81
+ );
82
+ if (explicitTargetPattern.test(normalizedText)) {
83
+ return selector;
84
+ }
85
+ const instructionWindowPattern = new RegExp(
86
+ `@${escapedSelector}(?:\\s*(?:${DIRECTED_MANAGED_REPLY_POSTPOSITION_PATTERN}))?(?:[^@\\n]{0,32}?)${DIRECTED_MANAGED_REPLY_ACTION_PATTERN}`,
87
+ "iu",
88
+ );
89
+ if (instructionWindowPattern.test(normalizedText)) {
90
+ return selector;
91
+ }
92
+ }
93
+ return "";
94
+ }
95
+
54
96
  export async function resolveHumanIntentContext({
55
97
  selectedRecord,
56
98
  normalizedRoute,
@@ -118,7 +160,7 @@ export async function resolveHumanIntentContext({
118
160
  runnerHumanIntentPromises.set(cacheKey, promise);
119
161
  }
120
162
  let humanIntent = await runnerHumanIntentPromises.get(cacheKey);
121
- const directedReplyTargetSelector = detectDirectedManagedReplyTarget({
163
+ const directedReplyTargetSelector = detectDirectedManagedReplyTargetV2({
122
164
  text: parsed.body,
123
165
  currentBotSelector,
124
166
  managedMentions,
@@ -740,11 +740,11 @@ export async function runSelftestRunnerScenarios(push, deps) {
740
740
  );
741
741
  }
742
742
 
743
- try {
744
- const pendingSelection = selectPendingArchiveComments(
745
- [
746
- {
747
- id: "dup-comment-1",
743
+ try {
744
+ const pendingSelection = selectPendingArchiveComments(
745
+ [
746
+ {
747
+ id: "dup-comment-1",
748
748
  createdAt: "2026-03-18T00:00:01.000Z",
749
749
  updatedAt: "2026-03-18T00:00:01.000Z",
750
750
  parsedArchive: { kind: "telegram_message", chatID: "-1001", messageID: 353, body: "@RyoAI_bot first copy" },
@@ -770,14 +770,148 @@ export async function runSelftestRunnerScenarios(push, deps) {
770
770
  } catch (err) {
771
771
  push(
772
772
  "runner_pending_selection_ignores_duplicate_archived_inbound_message_ids",
773
- false,
774
- String(err?.message || err),
775
- );
776
- }
777
-
773
+ false,
774
+ String(err?.message || err),
775
+ );
776
+ }
777
+
778
+ try {
779
+ const nowMs = Date.parse("2026-03-31T12:00:00.000Z");
780
+ const pendingSelection = selectPendingArchiveComments(
781
+ [
782
+ {
783
+ id: "cursor-comment",
784
+ createdAt: "2026-03-31T11:55:00.000Z",
785
+ updatedAt: "2026-03-31T11:55:00.000Z",
786
+ sourceOccurredAt: "2026-03-31T09:55:00.000Z",
787
+ parsedArchive: {
788
+ kind: "telegram_message",
789
+ chatID: "-1001",
790
+ messageID: 1145,
791
+ occurredAt: "2026-03-31T09:55:00.000Z",
792
+ body: "@RyoAI_bot 기준 댓글",
793
+ },
794
+ },
795
+ {
796
+ id: "stale-source-comment",
797
+ createdAt: "2026-03-31T11:59:58.000Z",
798
+ updatedAt: "2026-03-31T11:59:58.000Z",
799
+ sourceOccurredAt: "2026-03-31T10:00:00.000Z",
800
+ parsedArchive: {
801
+ kind: "telegram_message",
802
+ chatID: "-1001",
803
+ messageID: 325,
804
+ occurredAt: "2026-03-31T10:00:00.000Z",
805
+ body: "@RyoAI_bot 오래된 하이",
806
+ },
807
+ },
808
+ {
809
+ id: "fresh-source-comment",
810
+ createdAt: "2026-03-31T11:59:59.000Z",
811
+ updatedAt: "2026-03-31T11:59:59.000Z",
812
+ sourceOccurredAt: "2026-03-31T11:57:00.000Z",
813
+ parsedArchive: {
814
+ kind: "telegram_message",
815
+ chatID: "-1001",
816
+ messageID: 1146,
817
+ occurredAt: "2026-03-31T11:57:00.000Z",
818
+ body: "@RyoAI_bot 최신 하이",
819
+ },
820
+ },
821
+ ],
822
+ {
823
+ last_processed_comment_id: "cursor-comment",
824
+ },
825
+ "start",
826
+ (record) => record,
827
+ {
828
+ maxPendingAgeMs: 15 * 60 * 1000,
829
+ nowMs,
830
+ },
831
+ );
832
+ push(
833
+ "runner_pending_selection_uses_source_occurrence_time_for_stale_filtering",
834
+ pendingSelection.pending.length === 1
835
+ && String(pendingSelection.pending[0]?.id || "") === "fresh-source-comment"
836
+ && ensureArray(pendingSelection.staleSkipped).some((record) => String(record?.id || "") === "stale-source-comment"),
837
+ `pending=${pendingSelection.pending.map((item) => item.id).join(",") || "(none)"} stale=${ensureArray(pendingSelection.staleSkipped).map((item) => item.id).join(",") || "(none)"}`,
838
+ );
839
+ } catch (err) {
840
+ push(
841
+ "runner_pending_selection_uses_source_occurrence_time_for_stale_filtering",
842
+ false,
843
+ String(err?.message || err),
844
+ );
845
+ }
846
+
847
+ try {
848
+ const nowMs = Date.parse("2026-03-31T12:00:00.000Z");
849
+ const pendingSelection = selectPendingArchiveComments(
850
+ [
851
+ {
852
+ id: "cursor-comment-persisted-stale",
853
+ createdAt: "2026-03-31T11:55:00.000Z",
854
+ updatedAt: "2026-03-31T11:55:00.000Z",
855
+ parsedArchive: {
856
+ kind: "telegram_message",
857
+ chatID: "-1001",
858
+ messageID: 1148,
859
+ body: "@RyoAI_bot 기준 댓글",
860
+ },
861
+ },
862
+ {
863
+ id: "stale-by-persisted-cutoff",
864
+ createdAt: "2026-03-31T11:59:58.000Z",
865
+ updatedAt: "2026-03-31T11:59:58.000Z",
866
+ staleAfterAt: "2026-03-31T11:50:00.000Z",
867
+ parsedArchive: {
868
+ kind: "telegram_message",
869
+ chatID: "-1001",
870
+ messageID: 327,
871
+ body: "@RyoAI_bot archive created recently but source already stale",
872
+ },
873
+ },
874
+ {
875
+ id: "fresh-by-persisted-cutoff",
876
+ createdAt: "2026-03-31T11:59:59.000Z",
877
+ updatedAt: "2026-03-31T11:59:59.000Z",
878
+ staleAfterAt: "2026-03-31T12:05:00.000Z",
879
+ parsedArchive: {
880
+ kind: "telegram_message",
881
+ chatID: "-1001",
882
+ messageID: 1149,
883
+ body: "@RyoAI_bot still fresh by persisted cutoff",
884
+ },
885
+ },
886
+ ],
887
+ {
888
+ last_processed_comment_id: "cursor-comment-persisted-stale",
889
+ },
890
+ "start",
891
+ (record) => record,
892
+ {
893
+ maxPendingAgeMs: 15 * 60 * 1000,
894
+ nowMs,
895
+ },
896
+ );
897
+ push(
898
+ "runner_pending_selection_prefers_persisted_stale_after_at_over_recent_archive_created_at",
899
+ pendingSelection.pending.length === 1
900
+ && String(pendingSelection.pending[0]?.id || "") === "fresh-by-persisted-cutoff"
901
+ && ensureArray(pendingSelection.staleSkipped).some((record) => String(record?.id || "") === "stale-by-persisted-cutoff"),
902
+ `pending=${pendingSelection.pending.map((item) => item.id).join(",") || "(none)"} stale=${ensureArray(pendingSelection.staleSkipped).map((item) => item.id).join(",") || "(none)"}`,
903
+ );
904
+ } catch (err) {
905
+ push(
906
+ "runner_pending_selection_prefers_persisted_stale_after_at_over_recent_archive_created_at",
907
+ false,
908
+ String(err?.message || err),
909
+ );
910
+ }
911
+
778
912
  try {
779
913
  const duplicateComments = [
780
- {
914
+ {
781
915
  id: "dup-comment-earliest",
782
916
  createdAt: "2026-03-18T00:00:01.000Z",
783
917
  updatedAt: "2026-03-18T00:00:01.000Z",
@@ -3225,15 +3359,76 @@ export async function runSelftestRunnerScenarios(push, deps) {
3225
3359
  && aliasHumanIntentContext?.reusedPersistedContract === true,
3226
3360
  `managedMentions=${JSON.stringify(ensureArray(aliasHumanIntentContext?.managedMentions))} reused=${String(aliasHumanIntentContext?.reusedPersistedContract)}`,
3227
3361
  );
3228
- } catch (err) {
3229
- push(
3230
- "runner_human_intent_context_ignores_raw_alias_selector_for_managed_mentions",
3231
- false,
3232
- String(err?.message || err),
3233
- );
3234
- }
3235
-
3236
- const mentionOverridesReplyForUnmentionedBot = evaluateTelegramRunnerTrigger(
3362
+ } catch (err) {
3363
+ push(
3364
+ "runner_human_intent_context_ignores_raw_alias_selector_for_managed_mentions",
3365
+ false,
3366
+ String(err?.message || err),
3367
+ );
3368
+ }
3369
+
3370
+ try {
3371
+ const replyTargetIntentContext = await resolveHumanIntentContext({
3372
+ selectedRecord: {
3373
+ id: "comment-directed-reply-target-korean",
3374
+ parsedArchive: {
3375
+ kind: "telegram_message",
3376
+ body: "@RyoAI_bot 너가 @SangHoon01_bot 인사 시켜봐",
3377
+ senderIsBot: false,
3378
+ },
3379
+ },
3380
+ normalizedRoute: {
3381
+ name: "telegram-monitor-ryoai-bot-2",
3382
+ },
3383
+ bot: {
3384
+ username: "ryoai_bot",
3385
+ name: "RyoAI_bot",
3386
+ },
3387
+ executionPlan: {},
3388
+ deps: {
3389
+ resolveConversationPeerBots: () => [
3390
+ { id: "bot-self-1", name: "RyoAI_bot" },
3391
+ { id: "bot-peer-1", name: "SangHoon01_bot" },
3392
+ ],
3393
+ },
3394
+ intentDeps: {
3395
+ normalizeMentionSelector: normalizeSelftestMentionSelector,
3396
+ buildConversationPeerMap: (_bot, _route, runtimeDeps) => new Map(
3397
+ ensureArray(runtimeDeps?.resolveConversationPeerBots?.() || []).map((item) => {
3398
+ const selector = normalizeSelftestMentionSelector(item?.name || item?.username);
3399
+ return [selector, item];
3400
+ }).filter(([selector]) => selector),
3401
+ ),
3402
+ extractOrderedMentionSelectors: (text) => Array.from(String(text || "").matchAll(/@([A-Za-z0-9_]+)/g)).map((match) => normalizeSelftestMentionSelector(match[1] || "")),
3403
+ uniqueOrdered: normalizeSelftestConversationSelectorList,
3404
+ buildHumanIntentFromPersistedRunnerRequest: () => null,
3405
+ buildRunnerHumanIntentCacheKey: () => "directed-reply-target-korean",
3406
+ runnerHumanIntentPromises: new Map(),
3407
+ analyzeHumanConversationIntentWithContractResolver: async () => ({
3408
+ intentMode: "single_bot",
3409
+ replyExpectation: "actionable",
3410
+ intentType: "actionable_request",
3411
+ }),
3412
+ scheduleRunnerHumanIntentCacheCleanup: () => {},
3413
+ isCompleteHumanIntentContract: () => true,
3414
+ normalizeHumanIntentType: (value, fallback = "") => String(value || "").trim() || fallback,
3415
+ },
3416
+ });
3417
+ push(
3418
+ "runner_human_intent_context_detects_korean_directed_reply_target",
3419
+ String(replyTargetIntentContext?.humanIntent?.replyTargetBotSelector || "") === "sanghoon01_bot"
3420
+ && JSON.stringify(ensureArray(replyTargetIntentContext?.managedMentions || [])) === JSON.stringify(["ryoai_bot", "sanghoon01_bot"]),
3421
+ `reply_target=${String(replyTargetIntentContext?.humanIntent?.replyTargetBotSelector || "(none)")} managed=${JSON.stringify(ensureArray(replyTargetIntentContext?.managedMentions || []))}`,
3422
+ );
3423
+ } catch (err) {
3424
+ push(
3425
+ "runner_human_intent_context_detects_korean_directed_reply_target",
3426
+ false,
3427
+ String(err?.message || err),
3428
+ );
3429
+ }
3430
+
3431
+ const mentionOverridesReplyForUnmentionedBot = evaluateTelegramRunnerTrigger(
3237
3432
  {
3238
3433
  id: "comment-2b",
3239
3434
  parsedArchive: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.270",
3
+ "version": "0.2.272",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [