From 290507892d26a2bf349b55e8d8f1889f0d357416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BF=8A=E7=94=9F?= Date: Mon, 10 Aug 2026 16:57:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(lark):=20=E6=94=AF=E6=8C=81=E5=8F=91?= =?UTF-8?q?=E9=80=81=E8=80=85=E9=82=AE=E7=AE=B1=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 8 ++ src/core/session-manager.ts | 3 +- src/core/types.ts | 2 +- src/im/lark/identity-cache.ts | 109 ++++++++++++++----- test/identity-cache-message-fallback.test.ts | 45 ++++++++ test/initial-user-turn-opening.test.ts | 2 + test/prompt-builder.test.ts | 12 ++ 7 files changed, 152 insertions(+), 29 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index c6c9c669c..7d9bf618e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -19,6 +19,14 @@ _Avoid_: agent, app A continuing conversation between one chat anchor and one **Agent CLI**. _Avoid_: thread, task +**Sender Identity**: +The best-effort identity of the person or Bot that authored one inbound chat +message. It follows each message turn rather than being fixed to a **Session**; +multiple senders may therefore appear in one Session. Human sender identity may +include an app-scoped open ID, display name, and email when the chat platform +makes them available. Missing optional fields never block message delivery. +_Avoid_: session owner, card recipient + **Token Usage**: Token counts reported by an **Agent CLI** or its persisted transcript for a **Session**. Token In is the Agent CLI's native input-side total, including diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 73a787d6d..1b489d46a 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -735,7 +735,7 @@ function renderChatContextBlock(chatContext?: ChatContext): string { /** * Render a `` tag for prompt injection. Caller resolves the sender - * (open_id + type + optional name) via `resolveSender(...)` in identity-cache. + * (open_id + type + optional name/email) via `resolveSender(...)` in identity-cache. * Returns empty string when no sender data is available so the prompt stays * clean for synthetic flows (scheduled tasks, no-op spawns). */ @@ -743,6 +743,7 @@ export function renderSenderTag(sender?: ResolvedSender): string { if (!sender || !sender.openId) return ''; const attrs: string[] = [`type="${xmlEscape(sender.type)}"`, `open_id="${xmlEscape(sender.openId)}"`]; if (sender.name) attrs.push(`name="${xmlEscape(sender.name)}"`); + if (sender.email) attrs.push(`email="${xmlEscape(sender.email)}"`); return ``; } diff --git a/src/core/types.ts b/src/core/types.ts index d254cbe43..4c36095fa 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -216,7 +216,7 @@ export interface DaemonSession { pendingAttachments?: LarkAttachment[]; pendingMentions?: LarkMention[]; // @mentions from initial message, used when building prompt after repo selection pendingSubstituteTrigger?: import('../types.js').SubstituteTrigger; - /** Sender (open_id + type + resolved name) of the initial message — stashed + /** Sender (open_id + type + resolved name/email) of the initial message — stashed * so the deferred spawn after repo-selection still injects a tag * matching the original caller, not the user who clicked the card. */ pendingSender?: import('../im/lark/identity-cache.js').ResolvedSender; diff --git a/src/im/lark/identity-cache.ts b/src/im/lark/identity-cache.ts index 5514a8475..a947ec436 100644 --- a/src/im/lark/identity-cache.ts +++ b/src/im/lark/identity-cache.ts @@ -2,15 +2,17 @@ * User / bot identity cache for prompt injection. * * Lark events only carry the sender's open_id (no name). To inject a - * `` tag into the CLI prompt we need a name → open_id - * dictionary. Three population sources, ordered by cost: + * `` tag into the CLI + * prompt we need an identity dictionary keyed by open_id. Three population + * sources, ordered by cost: * * 1. mentions — free. Lark mention payloads carry (name, open_id) pairs, * so every @ that flows through us teaches the cache. * 2. sender — free, but only learns open_id + type, not name. - * 3. contact API — `contact.v3.user.get` for users; only used as fallback - * when 1+2 didn't give us a name. Requires `contact:user.base:readonly` - * (already in `BOTMUX_REQUIRED_SCOPES`). + * 3. contact API — `contact.v3.user.get` for users; used to fill a missing + * name and email. Requires `contact:user.base:readonly` plus + * `contact:user.email:readonly` for the email field (both are already in + * the setup scope manifest). * * Scope: per Lark app. Open_id values are app-scoped on Lark's side, so the * cache file follows the same `identities-${larkAppId}.json` shape as the @@ -31,6 +33,9 @@ export interface IdentityRecord { openId: string; type: IdentityType; name?: string; + email?: string; + /** A successful contact API lookup, including a valid "no email" result. */ + contactResolvedAt?: number; source: 'sender' | 'mention' | 'contact_api' | 'message_api' | 'bot_cross_ref' | 'bot_info'; updatedAt: number; } @@ -109,14 +114,21 @@ export function flushIdentityCacheSync(): void { } /** - * Merge a partial identity record into the cache. Existing `name` is preserved - * unless the incoming record carries a real name (no clobbering). Existing - * `type` is only overridden when the incoming value is more specific - * (anything other than `unknown`). + * Merge a partial identity record into the cache. Existing `name` and `email` + * are preserved unless the incoming record carries a real value (no + * clobbering). Existing `type` is only overridden when the incoming value is + * more specific (anything other than `unknown`). */ export function recordIdentity( larkAppId: string, - rec: { openId: string; type?: IdentityType; name?: string; source?: IdentityRecord['source'] }, + rec: { + openId: string; + type?: IdentityType; + name?: string; + email?: string; + contactResolvedAt?: number; + source?: IdentityRecord['source']; + }, ): void { if (!rec.openId) return; const store = getStore(larkAppId); @@ -126,12 +138,20 @@ export function recordIdentity( openId: rec.openId, type: incomingType ?? existing?.type ?? 'unknown', name: rec.name ?? existing?.name, + email: rec.email ?? existing?.email, + contactResolvedAt: rec.contactResolvedAt ?? existing?.contactResolvedAt, source: rec.source ?? existing?.source ?? 'sender', updatedAt: Date.now(), }; // Skip persist when nothing meaningful changed — avoids disk churn from // every sender event re-bumping updatedAt. - if (existing && existing.type === merged.type && existing.name === merged.name) { + if ( + existing + && existing.type === merged.type + && existing.name === merged.name + && existing.email === merged.email + && existing.contactResolvedAt === merged.contactResolvedAt + ) { return; } store.set(rec.openId, merged); @@ -173,13 +193,27 @@ export async function resolveName(larkAppId: string, openId: string): Promise { + if (!openId) return; + const cached = getIdentity(larkAppId, openId); + if (cached?.contactResolvedAt) return; if (cached?.type === 'bot' || cached?.type === 'app') return undefined; - if (scopeUnavailable.has(larkAppId)) return undefined; + if (scopeUnavailable.has(larkAppId)) return; const key = `${larkAppId}:${openId}`; let pending = inflight.get(key); if (!pending) { - pending = fetchUserName(larkAppId, openId); + pending = fetchUserProfile(larkAppId, openId); inflight.set(key, pending); // Identity-guarded cleanup. A request that times out is evicted by the // catch below; if its underlying fetch later settles, we must NOT clobber @@ -187,9 +221,9 @@ export async function resolveName(larkAppId: string, openId: string): Promise { if (inflight.get(key) === local) inflight.delete(key); }; @@ -209,20 +243,31 @@ export async function resolveName(larkAppId: string, openId: string): Promise { +async function fetchUserProfile(larkAppId: string, openId: string): Promise { try { const c = getBotClient(larkAppId); const res = await larkGet(c, `/open-apis/contact/v3/users/${encodeURIComponent(openId)}`, { user_id_type: 'open_id', }); if (res?.code === 0) { - const name: string | undefined = res.data?.user?.name; - if (name) { - recordIdentity(larkAppId, { openId, name, type: 'user', source: 'contact_api' }); - } + const rawName: unknown = res.data?.user?.name; + const rawEmail: unknown = res.data?.user?.email; + const name = typeof rawName === 'string' && rawName.trim() ? rawName.trim() : undefined; + const email = typeof rawEmail === 'string' && rawEmail.trim() ? rawEmail.trim() : undefined; + // Mark a successful lookup even when email is absent. Without this + // negative cache, users who do not have an email would trigger a contact + // API request on every message. Failed/time-out lookups never set it, so + // a later turn can retry. + recordIdentity(larkAppId, { + openId, + name, + email, + contactResolvedAt: Date.now(), + type: 'user', + source: 'contact_api', + }); return; } // 99991672 = app身份缺权限 (contact:user.base:readonly 没开) @@ -299,6 +344,7 @@ export interface ResolvedSender { openId: string; type: 'user' | 'bot'; name?: string; + email?: string; } /** @@ -311,9 +357,11 @@ export interface ResolvedSender { * (e.g. a known foreign-bot display name from `bot-openids-${appId}.json`) * win over cache. * - * Name resolution order (each step only runs if the prior didn't yield a name): + * Identity resolution order: * 1. hint / cache — free, in-memory. - * 2. contact API — users only; needs `contact:user.base:readonly`. + * 2. contact API — users only; fills missing name/email and needs + * `contact:user.base:readonly` plus `contact:user.email:readonly` for + * email. A successful no-email result is negatively cached. * 3. message.get(`with_sender_name=true`) — fallback when `messageId` is * supplied and steps 1–2 came up empty. Covers users AND bots, and works * without the contact scope (the server names whoever sent that message). @@ -339,9 +387,15 @@ export async function resolveSender( recordIdentity(larkAppId, { openId, type, source: 'sender' }); - let name = hint?.name ?? getIdentity(larkAppId, openId)?.name; - if (!name && type === 'user') { - name = await resolveName(larkAppId, openId); + let identity = getIdentity(larkAppId, openId); + let name = hint?.name ?? identity?.name; + if (type === 'user' && (!name || !identity?.contactResolvedAt)) { + // Call even when a mention already supplied name if this cache record has + // never been contact-enriched: old name-only caches must get one chance to + // learn email. + await ensureContactProfile(larkAppId, openId); + identity = getIdentity(larkAppId, openId); + name ??= identity?.name; } // Last-resort fallback: server-side sender_name via message.get. Covers the // gap the contact API can't (bots, missing scope, out-of-range users) but @@ -349,5 +403,6 @@ export async function resolveSender( if (!name && hint?.messageId) { name = await resolveNameViaMessage(larkAppId, openId, hint.messageId, type); } - return { openId, type, name }; + const email = type === 'user' ? identity?.email : undefined; + return { openId, type, name, email }; } diff --git a/test/identity-cache-message-fallback.test.ts b/test/identity-cache-message-fallback.test.ts index 7c2aec0d7..73d1434da 100644 --- a/test/identity-cache-message-fallback.test.ts +++ b/test/identity-cache-message-fallback.test.ts @@ -87,6 +87,51 @@ describe('resolveSender message.get fallback', () => { expect(getMessageDetail).toHaveBeenCalledOnce(); }); + it('resolves and caches a user email from the contact profile', async () => { + larkGet.mockResolvedValue({ + code: 0, + data: { user: { name: 'Alice', email: 'alice@example.com' } }, + }); + const first = await resolveSender(APP, 'ou_user_email', 'user', { messageId: 'om_email' }); + expect(first).toMatchObject({ + openId: 'ou_user_email', + type: 'user', + name: 'Alice', + email: 'alice@example.com', + }); + + larkGet.mockClear(); + const second = await resolveSender(APP, 'ou_user_email', 'user', { messageId: 'om_email_2' }); + expect(second?.email).toBe('alice@example.com'); + expect(larkGet).not.toHaveBeenCalled(); + }); + + it('contact-enriches a legacy name-only cache once to add email', async () => { + getMessageDetail.mockResolvedValue({ + items: [{ sender: { sender_name: 'Legacy User' } }], + }); + await resolveSender(APP, 'ou_legacy_email', 'app', { messageId: 'om_legacy_seed' }); + + larkGet.mockResolvedValue({ + code: 0, + data: { user: { name: 'Legacy User', email: 'legacy@example.com' } }, + }); + const enriched = await resolveSender(APP, 'ou_legacy_email', 'user', { messageId: 'om_legacy_user' }); + expect(enriched).toMatchObject({ name: 'Legacy User', email: 'legacy@example.com' }); + expect(larkGet).toHaveBeenCalledOnce(); + }); + + it('negatively caches a successful contact response with no email', async () => { + larkGet.mockResolvedValue({ code: 0, data: { user: { name: 'No Email' } } }); + const first = await resolveSender(APP, 'ou_no_email', 'user', { messageId: 'om_no_email' }); + expect(first).toMatchObject({ name: 'No Email', email: undefined }); + + larkGet.mockClear(); + const second = await resolveSender(APP, 'ou_no_email', 'user', { messageId: 'om_no_email_2' }); + expect(second?.email).toBeUndefined(); + expect(larkGet).not.toHaveBeenCalled(); + }); + it('caches the resolved name so a later resolve needs no second fetch', async () => { getMessageDetail.mockResolvedValue({ items: [{ sender: { sender_name: '杨志发' } }], diff --git a/test/initial-user-turn-opening.test.ts b/test/initial-user-turn-opening.test.ts index 8442a0dbb..c866d9b76 100644 --- a/test/initial-user-turn-opening.test.ts +++ b/test/initial-user-turn-opening.test.ts @@ -43,6 +43,7 @@ const mocks = vi.hoisted(() => { openId, type: senderType === 'app' || senderType === 'bot' ? 'bot' as const : 'user' as const, name: openId === 'ou_owner' ? '凡辞' : undefined, + email: openId === 'ou_owner' ? 'owner@example.com' : undefined, } : undefined )), @@ -338,6 +339,7 @@ describe('empty-started session — first real business turn must use the new-to expect(opening).not.toContain(''); // … with every per-turn datum still threaded through. expect(opening).toContain(''); expect(opening).toContain('ou_peer'); expect(opening).toContain(' { expect(out).toContain('name="张三"'); }); + it('includes and XML-escapes the optional sender email', () => { + const out = renderSenderTag({ + openId: 'ou_email', + type: 'user', + name: 'Alice', + email: 'alice&ops@example.com', + }); + expect(out).toBe( + '', + ); + }); + it('preserves bot type for foreign botmux peers', () => { const out = renderSenderTag({ openId: 'ou_b', type: 'bot', name: 'CoCo' }); expect(out).toContain('type="bot"'); From 975d860b72d36007dfb730577c9d7ace8f3d7189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BF=8A=E7=94=9F?= Date: Mon, 10 Aug 2026 22:02:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(reminder):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=20Owner=20=E5=AE=9A=E6=97=B6=E6=8F=90?= =?UTF-8?q?=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bot-registry.ts | 8 + src/core/dashboard-ipc-server.ts | 31 +- src/core/session-owner-reminder.ts | 234 ++++++++++++++ src/daemon.ts | 50 +++ src/dashboard.ts | 18 ++ src/dashboard/bot-payload.ts | 3 + src/dashboard/web/bot-defaults-page.tsx | 124 ++++++++ src/dashboard/web/bot-defaults.ts | 6 + src/dashboard/web/i18n.ts | 36 +++ src/dashboard/web/style.css | 18 ++ src/services/session-owner-notification.ts | 28 ++ .../session-owner-reminder-config-store.ts | 23 ++ src/services/session-owner-reminder-store.ts | 63 ++++ test/bot-config-store.test.ts | 21 ++ test/bot-registry.test.ts | 18 ++ test/dashboard-bot-defaults-layout.test.ts | 11 + test/dashboard-bot-payload.test.ts | 1 + test/session-owner-reminder.test.ts | 291 ++++++++++++++++++ 18 files changed, 976 insertions(+), 8 deletions(-) create mode 100644 src/core/session-owner-reminder.ts create mode 100644 src/services/session-owner-notification.ts create mode 100644 src/services/session-owner-reminder-config-store.ts create mode 100644 src/services/session-owner-reminder-store.ts create mode 100644 test/session-owner-reminder.test.ts diff --git a/src/bot-registry.ts b/src/bot-registry.ts index cfe1b1455..7627b826d 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -27,6 +27,10 @@ import type { FeedbackPolicy, FeedbackPolicyInput } from './services/feedback-po import { normalizeFeedbackPolicyLayer } from './services/feedback-policy-resolver.js'; import type { FeedbackWebhookDestination } from './services/feedback-outbox.js'; import { cliModelSupportsReasoningEffort, isConfigurableReasoningCliId, isCodexReasoningEffort } from './services/codex-reasoning-effort.js'; +import { + normalizeSessionOwnerReminderConfig, + type SessionOwnerReminderConfig, +} from './core/session-owner-reminder.js'; import type { VcMeetingConsumerAgentConfig, VcMeetingConsumerConfig, @@ -1383,6 +1387,9 @@ export interface BotConfig { * sessions are never suspended. See core/idle-worker-sweeper.ts. */ maxLiveWorkers?: number; + /** Periodically @ the persisted Session owner while selected actionable + * runtime states remain unchanged. Missing means disabled. */ + sessionOwnerReminder?: SessionOwnerReminderConfig; /** * When true, THIS bot's daemon watches host load/memory and DMs the bot owner * when the machine crosses into (and back out of) an overloaded state — a @@ -2942,6 +2949,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { && Number.isInteger(entry.maxLiveWorkers) && entry.maxLiveWorkers > 0 ? entry.maxLiveWorkers : undefined, + sessionOwnerReminder: normalizeSessionOwnerReminderConfig(entry.sessionOwnerReminder), // Only explicit true persisted (undefined = off), same as restrictGrantCommands. overloadAlert: entry.overloadAlert === true || undefined, vcMeetingAgent, diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index e15ee465b..4290e04cc 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -98,6 +98,9 @@ import { cleanupMaterializedDashboardImages, materializeDashboardImages } from ' import { getCliDisplayName } from '../im/lark/card-builder.js'; import { sessionConfiguredRuntimeDisplayName } from './cli-runtime-display.js'; import { locateLimiter } from './dashboard-locate.js'; +import { DEFAULT_SESSION_OWNER_REMINDER } from './session-owner-reminder.js'; +import { updateSessionOwnerReminderConfig } from '../services/session-owner-reminder-config-store.js'; +import { sendSessionOwnerThreadNotification } from '../services/session-owner-notification.js'; import { buildTerminalUrl } from './terminal-url.js'; import { dashboardEventBus } from './dashboard-events.js'; import { validateWorkingDir } from './working-dir.js'; @@ -2660,13 +2663,11 @@ ipcRoute('POST', '/api/sessions/:sessionId/locate', async (_req, res, params) => return jsonRes(res, 200, { ok: false, error: 'no_feishu_transport' }); } try { - const messageId = await replyMessage( - ctx.larkAppId, - ctx.rootMessageId, - ``, - 'text', - true, - ); + const messageId = await sendSessionOwnerThreadNotification({ + larkAppId: ctx.larkAppId, + rootMessageId: ctx.rootMessageId, + ownerOpenId: ctx.ownerOpenId, + }); jsonRes(res, 200, { ok: true, messageId }); } catch (err) { jsonRes(res, 502, { ok: false, error: String(err) }); @@ -3772,9 +3773,12 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { agentSelectionKey = selectionKeyForBot(cliId, wrapperCli ?? undefined); } catch { /* no registered bot */ } let maxLiveWorkers: number | null = null; + let sessionOwnerReminder = DEFAULT_SESSION_OWNER_REMINDER; try { - const m = getBot(cachedLarkAppId).config.maxLiveWorkers; + const botConfig = getBot(cachedLarkAppId).config; + const m = botConfig.maxLiveWorkers; if (typeof m === 'number' && Number.isInteger(m) && m > 0) maxLiveWorkers = m; + sessionOwnerReminder = botConfig.sessionOwnerReminder ?? DEFAULT_SESSION_OWNER_REMINDER; } catch { /* default unlimited */ } let logicalSessionCount = 0; let residentSessionCount = 0; @@ -3891,6 +3895,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { // value when this bot has no explicit override (prompt/global/off). skillInjectionDefault: globalBuiltinSkillInjectionDefault(), maxLiveWorkers, + sessionOwnerReminder, logicalSessionCount, residentSessionCount, dormantSessionCount, @@ -4828,6 +4833,16 @@ ipcRoute('PUT', '/api/bot-max-live-workers', async (req, res) => { jsonRes(res, 200, { ok: true, maxLiveWorkers: value }); }); +ipcRoute('PUT', '/api/bot-session-owner-reminder', async (req, res) => { + if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); + let raw: unknown; + try { raw = await readJsonBody(req); } + catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + const result = await updateSessionOwnerReminderConfig(cachedLarkAppId, raw); + if (!result.ok) return jsonRes(res, 400, { ok: false, error: result.reason }); + return jsonRes(res, 200, { ok: true, sessionOwnerReminder: result.config }); +}); + // Per-bot skill policy. Dashboard uses this for attach/detach; JSON policy // still shares the same applyConfigField path as /botconfig. ipcRoute('PUT', '/api/bot-skills', async (req, res) => { diff --git a/src/core/session-owner-reminder.ts b/src/core/session-owner-reminder.ts new file mode 100644 index 000000000..5caf803dc --- /dev/null +++ b/src/core/session-owner-reminder.ts @@ -0,0 +1,234 @@ +import { createHash } from 'node:crypto'; +import type { DaemonSession } from './types.js'; + +export const SESSION_OWNER_REMINDER_STATES = [ + 'idle', + 'dormant', + 'pending_repo', + 'tui_prompt', + 'agent_attention', + 'limited', +] as const; + +export type SessionOwnerReminderState = typeof SESSION_OWNER_REMINDER_STATES[number]; + +export interface SessionOwnerReminderConfig { + enabled: boolean; + intervalMinutes: number; + text: string; + states: SessionOwnerReminderState[]; +} + +export interface SessionOwnerReminderRecord { + sessionId: string; + stateFingerprint: string; + actionableSince: number; + lastObservedActivityAt: number; + lastRemindedAt?: number; + retryAfterAt?: number; +} + +export type SessionOwnerReminderRecords = Record; + +export const DEFAULT_SESSION_OWNER_REMINDER: SessionOwnerReminderConfig = { + enabled: false, + intervalMinutes: 30, + text: '该会话已等待处理,请继续跟进。', + states: [...SESSION_OWNER_REMINDER_STATES], +}; + +const MIN_INTERVAL_MINUTES = 1; +const MAX_INTERVAL_MINUTES = 10_080; +const MAX_TEXT_CHARS = 500; +const FAILURE_RETRY_MAX_MS = 5 * 60_000; + +function isState(value: unknown): value is SessionOwnerReminderState { + return typeof value === 'string' + && (SESSION_OWNER_REMINDER_STATES as readonly string[]).includes(value); +} + +/** Strict normalizer shared by config loading and write validation. */ +export function normalizeSessionOwnerReminderConfig( + raw: unknown, +): SessionOwnerReminderConfig | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const value = raw as Record; + if (typeof value.enabled !== 'boolean') return undefined; + if (!Number.isInteger(value.intervalMinutes) + || (value.intervalMinutes as number) < MIN_INTERVAL_MINUTES + || (value.intervalMinutes as number) > MAX_INTERVAL_MINUTES) return undefined; + if (typeof value.text !== 'string') return undefined; + const text = value.text.trim(); + if (!text || Array.from(text).length > MAX_TEXT_CHARS || /<\s*at\b/i.test(text)) return undefined; + if (!Array.isArray(value.states)) return undefined; + const states = [...new Set(value.states.filter(isState))]; + if (states.length !== value.states.length) return undefined; + if (value.enabled && states.length === 0) return undefined; + return { + enabled: value.enabled, + intervalMinutes: value.intervalMinutes as number, + text, + states, + }; +} + +export function deriveSessionOwnerReminderStates(ds: DaemonSession): SessionOwnerReminderState[] { + const states: SessionOwnerReminderState[] = []; + const workerAlive = !!ds.worker && !ds.worker.killed; + if (workerAlive && ds.lastScreenStatus === 'idle') states.push('idle'); + // A pre-spawn repository picker is its own actionable state, not a released + // CLI. Likewise stale screen status belongs to the old worker lifetime. + if (!workerAlive && !ds.pendingRepo) states.push('dormant'); + if (ds.pendingRepo) states.push('pending_repo'); + if (ds.tuiPromptCardId) states.push('tui_prompt'); + if (ds.agentAttention) states.push('agent_attention'); + if (workerAlive && ds.lastScreenStatus === 'limited') states.push('limited'); + return states; +} + +/** + * Observation fingerprint used both to detect a state transition (reset the + * quiet period) and to seed the per-cycle delivery UUID. It must capture not + * only WHICH runtime signals are present but also their INSTANCE identity for + * the signals that get replaced in place while the label stays constant: + * + * - `agent_attention` → a fresh `{kind, reason, at}` is written on every new + * attention raise, so key on `agentAttention.at`. + * - `tui_prompt` → an old TUI prompt card is cleared and a new + * `tuiPromptCardId` set between scans, so key on that id. + * + * Without the instance component, the old signal being resolved and a brand-new + * same-category signal appearing (e.g. a second, unrelated attention request) + * would inherit the previous signal's elapsed quiet time and could @ the owner + * almost immediately — undercutting the "give N minutes to handle THIS" promise. + * + * The remaining states (idle / dormant / pending_repo / limited) have no + * distinct replaceable instance, so their label alone is a faithful key. + * + * INVARIANT: for a fixed instance the fingerprint is stable across scans, so a + * within-cycle failed-send retry recomputes the SAME fingerprint → the same + * delivery UUID → the existing Lark dedupe window still suppresses duplicates. + * A new instance changes the fingerprint, which resets the record and thereby + * starts a fresh cycle whose eventual UUID is legitimately different. + */ +export function sessionOwnerReminderObservationFingerprint( + ds: DaemonSession, + states: SessionOwnerReminderState[], +): string { + return states + .map(state => { + if (state === 'agent_attention') return `agent_attention:${ds.agentAttention?.at ?? ''}`; + if (state === 'tui_prompt') return `tui_prompt:${ds.tuiPromptCardId ?? ''}`; + return state; + }) + .join(','); +} + +export interface SessionOwnerReminderControllerDeps { + load(): SessionOwnerReminderRecords; + save(records: SessionOwnerReminderRecords): void; + send(ds: DaemonSession, text: string, uuid: string): Promise; + canSend(ds: DaemonSession): boolean; + onError?(ds: DaemonSession, error: unknown): void; +} + +function recordsSnapshot(value: unknown): string { + return JSON.stringify(value); +} + +export function sessionOwnerReminderDeliveryUuid( + sessionId: string, + stateFingerprint: string, + dueBase: number, +): string { + const digest = createHash('sha256') + .update(`${sessionId}\0${stateFingerprint}\0${dueBase}`, 'utf8') + .digest('hex') + .slice(0, 32); + return `owner-reminder-${digest}`; +} + +/** Durable, deterministic scan engine. Scheduling and Lark IO are injected. */ +export class SessionOwnerReminderController { + constructor(private readonly deps: SessionOwnerReminderControllerDeps) {} + + async scan( + sessions: Iterable, + config: SessionOwnerReminderConfig, + now: number = Date.now(), + ): Promise { + const current = this.deps.load(); + const before = recordsSnapshot(current); + if (!config.enabled || config.states.length === 0) { + if (Object.keys(current).length > 0) this.deps.save({}); + return; + } + + const configured = new Set(config.states); + const seen = new Set(); + const intervalMs = config.intervalMinutes * 60_000; + + for (const ds of sessions) { + const sessionId = ds.session.sessionId; + if (ds.session.status !== 'active' + || ds.session.queued === true + || (ds.scope ?? ds.session.scope) !== 'thread' + || !ds.session.ownerOpenId + || !this.deps.canSend(ds)) { + delete current[sessionId]; + continue; + } + + const projectedStates = deriveSessionOwnerReminderStates(ds); + const matched = projectedStates.filter(state => configured.has(state)); + if (matched.length === 0) { + delete current[sessionId]; + continue; + } + seen.add(sessionId); + // Eligibility follows the configured subset, but timer resets follow the + // complete runtime state. An unselected attention signal still represents + // a state transition and starts a fresh quiet period. The fingerprint also + // captures instance identity (attention.at / tuiPromptCardId) so a NEW + // same-category signal replacing a resolved one resets the timer instead + // of inheriting the old quiet period — see the fingerprint helper above. + const stateFingerprint = sessionOwnerReminderObservationFingerprint(ds, projectedStates); + const activityAt = Number.isFinite(ds.lastMessageAt) ? ds.lastMessageAt : 0; + let record = current[sessionId]; + + if (!record + || record.stateFingerprint !== stateFingerprint + || activityAt > record.lastObservedActivityAt) { + record = current[sessionId] = { + sessionId, + stateFingerprint, + actionableSince: Math.max(now, activityAt), + lastObservedActivityAt: activityAt, + }; + continue; + } + + const dueBase = record.lastRemindedAt ?? record.actionableSince; + if (now - dueBase < intervalMs) continue; + if (record.retryAfterAt !== undefined && now < record.retryAfterAt) continue; + + try { + await this.deps.send( + ds, + config.text, + sessionOwnerReminderDeliveryUuid(sessionId, stateFingerprint, dueBase), + ); + record.lastRemindedAt = now; + record.retryAfterAt = undefined; + } catch (error) { + record.retryAfterAt = now + Math.max(60_000, Math.min(intervalMs, FAILURE_RETRY_MAX_MS)); + this.deps.onError?.(ds, error); + } + } + + for (const sessionId of Object.keys(current)) { + if (!seen.has(sessionId)) delete current[sessionId]; + } + if (recordsSnapshot(current) !== before) this.deps.save(current); + } +} diff --git a/src/daemon.ts b/src/daemon.ts index 67edd3676..1d8a99c24 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -272,6 +272,15 @@ import { HerdrBackend } from './adapters/backend/herdr-backend.js'; import { ZellijBackend } from './adapters/backend/zellij-backend.js'; import { ZmxBackend } from './adapters/backend/zmx-backend.js'; import { sweepIdleWorkersAfterTurnDrain, DEFAULT_MAX_LIVE_WORKERS } from './core/idle-worker-sweeper.js'; +import { + DEFAULT_SESSION_OWNER_REMINDER, + SessionOwnerReminderController, +} from './core/session-owner-reminder.js'; +import { + loadSessionOwnerReminderRecords, + saveSessionOwnerReminderRecords, +} from './services/session-owner-reminder-store.js'; +import { sendSessionOwnerThreadNotification } from './services/session-owner-notification.js'; import { getSessionPersistentBackendType, isRemoteBackendSession, @@ -22046,6 +22055,45 @@ export async function startDaemon(botIndex?: number): Promise { }, 60_000); idleWorkerSweepTimer.unref?.(); + const sessionOwnerReminder = new SessionOwnerReminderController({ + load: () => loadSessionOwnerReminderRecords(config.session.dataDir, cfg.larkAppId), + save: records => saveSessionOwnerReminderRecords(config.session.dataDir, cfg.larkAppId, records), + canSend: ds => larkTransportEnabled({ + chatId: ds.chatId, + apiOnly: getBot(ds.larkAppId).config.apiOnly, + }), + send: async (ds, text, uuid) => { + await sendSessionOwnerThreadNotification({ + larkAppId: ds.larkAppId, + rootMessageId: ds.session.rootMessageId, + ownerOpenId: ds.session.ownerOpenId!, + }, text, uuid); + }, + onError: (ds, error) => logger.warn( + `[session-owner-reminder] send failed session=${ds.session.sessionId}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ), + }); + let sessionOwnerReminderScanInFlight = false; + const scanSessionOwnerReminders = async (): Promise => { + if (sessionOwnerReminderScanInFlight) return; + sessionOwnerReminderScanInFlight = true; + try { + const currentConfig = getBot(cfg.larkAppId).config.sessionOwnerReminder + ?? DEFAULT_SESSION_OWNER_REMINDER; + await sessionOwnerReminder.scan(activeSessions.values(), currentConfig); + } catch (error) { + logger.warn(`[session-owner-reminder] scan failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + sessionOwnerReminderScanInFlight = false; + } + }; + const sessionOwnerReminderTimer = setInterval(() => { + void scanSessionOwnerReminders(); + }, 60_000); + sessionOwnerReminderTimer.unref?.(); + void scanSessionOwnerReminders(); + // Periodic sandbox reconciler: the daemon's SIGKILL straggler-reaper (and any // worker SIGKILL) bypasses worker-side killCli(), so the pre-created deny-mask // mountpoints + per-session tree of a killed-but-still-active sandboxed session @@ -22402,6 +22450,7 @@ export async function startDaemon(botIndex?: number): Promise { for (const key of [...vcMeetingPendingInvites.keys()]) deleteVcMeetingPendingInvite(key); clearInterval(descriptorHeartbeat); clearInterval(idleWorkerSweepTimer); + clearInterval(sessionOwnerReminderTimer); if (memoryDiagnostics) clearInterval(memoryDiagnostics); removeDaemonDescriptor(cfg.larkAppId); ipcHandle.close().catch(() => { /* swallow */ }); @@ -22613,6 +22662,7 @@ export async function startDaemon(botIndex?: number): Promise { setSupervisorShutdownHandler(null); clearInterval(descriptorHeartbeat); clearInterval(idleWorkerSweepTimer); + clearInterval(sessionOwnerReminderTimer); clearInterval(docCommentPollTimer); if (memoryDiagnostics) clearInterval(memoryDiagnostics); removeDaemonDescriptor(cfg.larkAppId); diff --git a/src/dashboard.ts b/src/dashboard.ts index aded2b0a7..3c05fcbba 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -6221,6 +6221,24 @@ const server = createServer(async (req, res) => { }); } + // PUT /api/bots/:appId/session-owner-reminder — per-Bot periodic owner + // reminder policy. The owning daemon validates, persists, and hot-applies. + let mBotOwnerReminder: RegExpMatchArray | null; + if (req.method === 'PUT' && (mBotOwnerReminder = url.pathname.match(/^\/api\/bots\/([^/]+)\/session-owner-reminder$/))) { + const appId = decodeURIComponent(mBotOwnerReminder[1]); + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + const upstream = await proxyToDaemon(appId, `/api/bot-session-owner-reminder`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(await upstream.text()); + return; + } + // Create a new chat — pick a creator from the user-selected larkAppIds // (Feishu makes the calling bot the implicit first member, so picking // anything else would silently add an unwanted bot). Auto-invite the diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index 57cbbab8f..d50f51701 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -133,6 +133,9 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: logicalSessionCount: typeof j?.logicalSessionCount === 'number' ? j.logicalSessionCount : 0, residentSessionCount: typeof j?.residentSessionCount === 'number' ? j.residentSessionCount : 0, dormantSessionCount: typeof j?.dormantSessionCount === 'number' ? j.dormantSessionCount : 0, + sessionOwnerReminder: j?.sessionOwnerReminder && typeof j.sessionOwnerReminder === 'object' + ? j.sessionOwnerReminder + : null, startupCommands: typeof j?.startupCommands === 'string' ? j.startupCommands : '', customPassthroughCommands: typeof j?.customPassthroughCommands === 'string' ? j.customPassthroughCommands : '', canTalkDaemonCommands: typeof j?.canTalkDaemonCommands === 'string' ? j.canTalkDaemonCommands : '', diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 666296f48..9f7776628 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -939,6 +939,7 @@ function BotDefaultsCard(props: {
) : null}
+
@@ -1022,6 +1023,129 @@ function RuntimeEnvironmentSection(props: { bot: BotDefaultsRow; patchBot: Patch ); } +type OwnerReminderState = NonNullable['states'][number]; +const OWNER_REMINDER_STATE_OPTIONS = [ + { value: 'idle', labelKey: 'botDefaults.ownerReminderStateIdle' }, + { value: 'dormant', labelKey: 'botDefaults.ownerReminderStateDormant' }, + { value: 'pending_repo', labelKey: 'botDefaults.ownerReminderStatePendingRepo' }, + { value: 'tui_prompt', labelKey: 'botDefaults.ownerReminderStateTuiPrompt' }, + { value: 'agent_attention', labelKey: 'botDefaults.ownerReminderStateAgentAttention' }, + { value: 'limited', labelKey: 'botDefaults.ownerReminderStateLimited' }, +] as const; + +// Offline/error rows can lack the daemon-provided default payload. Keep this +// browser fallback aligned with DEFAULT_SESSION_OWNER_REMINDER. +const DEFAULT_OWNER_REMINDER = { + enabled: false, + intervalMinutes: 30, + text: '该会话已等待处理,请继续跟进。', + states: OWNER_REMINDER_STATE_OPTIONS.map(option => option.value), +}; + +function SessionOwnerReminderSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { + const tr = useT(); + const initial = props.bot.sessionOwnerReminder ?? DEFAULT_OWNER_REMINDER; + const [enabled, setEnabled] = useState(initial.enabled === true); + const [interval, setIntervalValue] = useState(String(initial.intervalMinutes)); + const [text, setText] = useState(initial.text); + const [states, setStates] = useState([...initial.states]); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + const next = props.bot.sessionOwnerReminder ?? DEFAULT_OWNER_REMINDER; + setEnabled(next.enabled === true); + setIntervalValue(String(next.intervalMinutes)); + setText(next.text); + setStates([...next.states]); + }, [props.bot.sessionOwnerReminder]); + + function toggleState(state: OwnerReminderState, checked: boolean): void { + setStates(current => checked + ? (current.includes(state) ? current : [...current, state]) + : current.filter(item => item !== state)); + } + + async function save(): Promise { + const minutes = Number(interval); + const cleanText = text.trim(); + if (!Number.isInteger(minutes) || minutes < 1 || minutes > 10_080) { + setStatus({ text: `✗ ${tr('botDefaults.ownerReminderIntervalInvalid')}` }); + return; + } + if (!cleanText || Array.from(cleanText).length > 500 || /<\s*at\b/i.test(cleanText)) { + setStatus({ text: `✗ ${tr('botDefaults.ownerReminderTextInvalid')}` }); + return; + } + if (enabled && states.length === 0) { + setStatus({ text: `✗ ${tr('botDefaults.ownerReminderStatesInvalid')}` }); + return; + } + setBusy(true); + setStatus(null); + try { + const payload = { enabled, intervalMinutes: minutes, text: cleanText, states }; + const res = await sendJson( + 'PUT', + `/api/bots/${encodeURIComponent(props.bot.larkAppId)}/session-owner-reminder`, + payload, + ); + if (res.ok && res.body.ok) { + const next = res.body.sessionOwnerReminder ?? payload; + props.patchBot(props.bot.larkAppId, { sessionOwnerReminder: next }); + setStatus({ text: `✓ ${tr('botDefaults.cardPrefSaved')}`, ok: true }); + } else { + setStatus({ text: `✗ ${responseErrorText(res)}` }); + } + } catch (error: any) { + setStatus({ text: `✗ ${caughtErrorText(error)}` }); + } finally { + setBusy(false); + } + } + + return ( +
+

{tr('botDefaults.ownerReminderTitle')}

+ +
+ +
+
+

{tr('botDefaults.ownerReminderStates')}

+
+ {OWNER_REMINDER_STATE_OPTIONS.map(option => ( + + ))} +
+
+
+