Skip to main content

heartbit_core/config/
daemon.rs

1use serde::Deserialize;
2
3use crate::Error;
4
5/// Daemon mode configuration for runtime execution.
6///
7/// When `kafka` is absent, the daemon runs in HTTP-only mode: it serves
8/// `/v1/tasks/execute` for cloud-delegated execution but does not consume
9/// Kafka commands or produce events. This is the recommended mode for the
10/// 3-tier architecture where Kafka lives in the gateway, not the runtime.
11///
12/// Input-source fields (schedules, sensors, ws, telegram, heartbit_pulse,
13/// owner_emails, mcp_server) have been moved to the gateway crate as part
14/// of the 3-tier architecture refactoring. The type definitions are kept
15/// here so they can be re-used by the gateway.
16#[derive(Debug, Clone, Default, Deserialize)]
17#[non_exhaustive]
18pub struct DaemonConfig {
19    /// Kafka transport configuration. Omit for in-process channel mode.
20    #[serde(default)]
21    pub kafka: Option<KafkaConfig>,
22    /// HTTP bind address (e.g. `0.0.0.0:8080`).
23    #[serde(default = "default_daemon_bind")]
24    pub bind: String,
25    /// Maximum concurrently-executing tasks across all consumers.
26    #[serde(default = "default_max_concurrent")]
27    pub max_concurrent_tasks: usize,
28    /// Prometheus metrics configuration. Metrics are enabled by default.
29    #[serde(default)]
30    pub metrics: Option<MetricsConfig>,
31    /// PostgreSQL URL for durable task persistence. When absent, tasks are
32    /// stored in-memory (lost on restart).
33    #[serde(default)]
34    pub database_url: Option<String>,
35    /// HTTP API authentication configuration.
36    pub auth: Option<AuthConfig>,
37    /// Memory access control configuration.
38    #[serde(default)]
39    pub memory: DaemonMemoryConfig,
40    /// Audit log retention configuration.
41    #[serde(default)]
42    pub audit: DaemonAuditConfig,
43    /// Idempotency-key TTL sweep configuration.
44    #[serde(default)]
45    pub idempotency: IdempotencyConfig,
46    /// Per-persona X/Twitter mention-poll configurations.
47    /// Each entry launches a `MentionPollScheduler` that fires
48    /// `DaemonCommand::MentionPoll` on its configured interval.
49    #[cfg(feature = "ghost-domain-config")]
50    #[serde(default)]
51    pub persona_mentions: Vec<PersonaMentionsConfig>,
52    /// Per-persona proactive-posting configuration. One entry per
53    /// persona that has proactive posting enabled.
54    #[cfg(feature = "ghost-domain-config")]
55    #[serde(default)]
56    pub persona_posts: Vec<PersonaPostsConfig>,
57    /// Per-persona quote-tweet configurations. Each entry launches a
58    /// `PersonaQuoteScheduler`.
59    #[cfg(feature = "ghost-domain-config")]
60    #[serde(default)]
61    pub persona_quotes: Vec<PersonaQuotesConfig>,
62    /// Personal blog configuration. Single block (one blog per daemon).
63    /// When absent, the daemon does not spawn a blog scheduler.
64    #[cfg(feature = "ghost-domain-config")]
65    #[serde(default)]
66    pub persona_blog: Option<PersonaBlogConfig>,
67}
68
69/// MCP server configuration for the daemon.
70///
71/// When present, the daemon exposes an MCP-compatible endpoint at `/mcp`
72/// so external MCP clients can discover and call heartbit tools/resources.
73#[derive(Debug, Clone, Deserialize)]
74pub struct DaemonMcpServerConfig {
75    /// Server name reported in the `initialize` response. Defaults to `"heartbit"`.
76    #[serde(default = "default_mcp_server_name")]
77    pub name: String,
78    /// Whether to expose heartbit tools via MCP. Default: `true`.
79    #[serde(default = "super::default_true")]
80    pub expose_tools: bool,
81    /// Whether to expose resources (tasks, memory, knowledge) via MCP. Default: `true`.
82    #[serde(default = "super::default_true")]
83    pub expose_resources: bool,
84    /// Whether to expose prompts via MCP. Default: `false`.
85    #[serde(default)]
86    pub expose_prompts: bool,
87}
88
89fn default_mcp_server_name() -> String {
90    "heartbit".into()
91}
92
93/// Audit log retention configuration for the daemon.
94///
95/// Controls automatic pruning of old audit log entries.
96/// When `retain_days` is set and a Postgres store is attached, the daemon
97/// spawns a background task that deletes entries older than `retain_days`.
98#[derive(Debug, Clone, Default, Deserialize)]
99pub struct DaemonAuditConfig {
100    /// Number of days to retain audit log entries. Entries older than this
101    /// are deleted by the background prune task. `None` disables pruning.
102    #[serde(default)]
103    pub retain_days: Option<u32>,
104    /// Interval in minutes between prune runs. Defaults to 60 (hourly).
105    #[serde(default)]
106    pub prune_interval_minutes: Option<u64>,
107}
108
109/// Idempotency-key sweep settings.
110///
111/// When `ttl_hours` is `Some`, the daemon runs a background task that nulls
112/// out idempotency keys older than the TTL. The row itself is retained so
113/// existing primary-key lookups still work; only the dedup contract expires.
114#[derive(Debug, Clone, Default, Deserialize)]
115pub struct IdempotencyConfig {
116    /// Hours to retain idempotency keys before the sweep nulls them out.
117    /// Default `None` disables the sweep.
118    #[serde(default)]
119    pub ttl_hours: Option<u32>,
120    /// How often the sweep runs, in minutes. Default 60.
121    #[serde(default)]
122    pub sweep_interval_minutes: Option<u32>,
123}
124
125/// Per-persona X/Twitter mention-poll configuration.
126///
127/// Each entry in `[[daemon.persona_mentions]]` configures one persona's
128/// periodic mention polling loop. When `enabled = true`, the daemon spawns
129/// a `MentionPollScheduler` for each entry that fires a
130/// `DaemonCommand::MentionPoll` on the configured interval.
131#[cfg(feature = "ghost-domain-config")]
132#[derive(Debug, Clone, Deserialize)]
133pub struct PersonaMentionsConfig {
134    /// Persona slug (must match a loaded persona file, e.g. `"heartbit-ghost"`).
135    pub persona: String,
136    /// Enable mention polling for this persona. Defaults to `true`.
137    #[serde(default = "super::default_true")]
138    pub enabled: bool,
139    /// Seconds between mention polls. Defaults to 300 (5 minutes, X API rate-limit-safe).
140    #[serde(default = "default_poll_interval_seconds")]
141    pub poll_interval_seconds: u64,
142    /// X/Twitter user-id to poll mentions for (e.g. `"100"`).
143    pub user_id: String,
144    /// Maximum candidates to generate replies for per poll cycle. Defaults to 5.
145    #[serde(default = "default_candidates_per_reply")]
146    pub candidates_per_reply: usize,
147    /// Which mention store backend to use: `"in_memory"` or `"jsonl"`.
148    /// Defaults to `"in_memory"`.
149    #[serde(default = "default_mention_store")]
150    pub mention_store: String,
151    /// File path for the JSONL mention store (only used when
152    /// `mention_store = "jsonl"`). When absent, a per-persona default path is
153    /// derived from the persona slug.
154    #[serde(default)]
155    pub mention_store_path: Option<String>,
156
157    // ── P1.7 loop-protection guards ─────────────────────────────────────────
158    /// Enable the thread-depth guard (skips mentions whose parent tweet was
159    /// already replied to by this persona). Defaults to `true`.
160    #[serde(default = "super::default_true")]
161    pub enable_thread_depth_guard: bool,
162    /// Enable the bot-heuristic guard. Defaults to `true`.
163    #[serde(default = "super::default_true")]
164    pub enable_bot_heuristic_guard: bool,
165    /// Extra substrings that flag a handle as suspicious (supplements
166    /// built-in defaults when non-empty). Defaults to `[]` (use built-ins).
167    #[serde(default)]
168    pub suspicious_handle_patterns: Vec<String>,
169    /// Minimum follower/following ratio; below this is one bot signal.
170    /// Defaults to 0.05.
171    #[serde(default = "default_min_follower_following_ratio")]
172    pub min_follower_following_ratio: f32,
173    /// Minimum account age in days; younger accounts trigger one bot signal.
174    /// Defaults to 7.
175    #[serde(default = "default_min_account_age_days")]
176    pub min_account_age_days: i64,
177    /// Number of bot signals required to skip a mention. Defaults to 2.
178    #[serde(default = "default_bot_heuristic_threshold")]
179    pub bot_heuristic_threshold: usize,
180    /// Maximum replies per unique conversation. Defaults to 2.
181    #[serde(default = "default_per_conversation_max_replies")]
182    pub per_conversation_max_replies: usize,
183    /// Daily LLM token budget per persona. `null` (default) disables the cap.
184    #[serde(default)]
185    pub daily_token_budget: Option<u64>,
186    /// Budget tracker backend: `"in_memory"` or `"jsonl"`. Defaults to
187    /// `"in_memory"`.
188    #[serde(default = "default_p1_7_budget_store")]
189    pub budget_store: String,
190    /// File path for the JSONL budget store (only used when
191    /// `budget_store = "jsonl"`). Required when `budget_store = "jsonl"`.
192    #[serde(default)]
193    pub budget_path: Option<String>,
194}
195
196#[cfg(feature = "ghost-domain-config")]
197fn default_poll_interval_seconds() -> u64 {
198    300
199}
200
201#[cfg(feature = "ghost-domain-config")]
202fn default_candidates_per_reply() -> usize {
203    2
204}
205
206#[cfg(feature = "ghost-domain-config")]
207fn default_mention_store() -> String {
208    "in_memory".into()
209}
210
211#[cfg(feature = "ghost-domain-config")]
212fn default_min_follower_following_ratio() -> f32 {
213    0.05
214}
215
216#[cfg(feature = "ghost-domain-config")]
217fn default_min_account_age_days() -> i64 {
218    7
219}
220
221#[cfg(feature = "ghost-domain-config")]
222fn default_bot_heuristic_threshold() -> usize {
223    2
224}
225
226#[cfg(feature = "ghost-domain-config")]
227fn default_per_conversation_max_replies() -> usize {
228    2
229}
230
231#[cfg(feature = "ghost-domain-config")]
232fn default_p1_7_budget_store() -> String {
233    "in_memory".into()
234}
235
236/// Memory access control configuration for the daemon.
237///
238/// Controls which users can write to shared institutional memory.
239#[derive(Debug, Clone, Default, Deserialize)]
240pub struct DaemonMemoryConfig {
241    /// Roles that are allowed to write to shared institutional memory.
242    ///
243    /// When empty (the default), all users can write — backward compatible.
244    /// When non-empty, only users with at least one of these roles can write.
245    ///
246    /// Example: `["admin", "knowledge_manager"]`
247    #[serde(default)]
248    pub shared_write_roles: Vec<String>,
249}
250
251/// HTTP API authentication configuration for the daemon.
252#[derive(Debug, Clone, Deserialize)]
253pub struct AuthConfig {
254    /// Bearer tokens that grant API access. Multiple tokens support key rotation.
255    #[serde(default)]
256    pub bearer_tokens: Vec<String>,
257    /// JWKS endpoint URL for JWT signature verification
258    /// (e.g. `"https://idp.example.com/.well-known/jwks.json"`).
259    pub jwks_url: Option<String>,
260    /// Expected JWT issuer (`iss` claim). Validated when present.
261    pub issuer: Option<String>,
262    /// Expected JWT audience (`aud` claim). Validated when present.
263    pub audience: Option<String>,
264    /// JWT claim to extract user ID from. Defaults to `"sub"`.
265    pub user_id_claim: Option<String>,
266    /// JWT claim to extract tenant ID from. Defaults to `"tid"`.
267    pub tenant_id_claim: Option<String>,
268    /// JWT claim to extract roles from. Defaults to `"roles"`.
269    pub roles_claim: Option<String>,
270    /// RFC 8693 Token Exchange configuration for per-user MCP auth delegation.
271    /// When configured, the daemon exchanges user JWTs for MCP-scoped delegated tokens.
272    pub token_exchange: Option<TokenExchangeConfig>,
273}
274
275/// RFC 8693 Token Exchange configuration for per-user MCP auth delegation.
276///
277/// When configured, each task submitted with a JWT gets a user-scoped delegated
278/// token injected into MCP requests. The daemon acts as the agent (actor) and
279/// exchanges the user's subject token for a scoped access token.
280#[derive(Debug, Clone, Deserialize)]
281pub struct TokenExchangeConfig {
282    /// Token exchange endpoint URL (e.g. `"https://idp.example.com/oauth/token"`).
283    pub exchange_url: String,
284    /// OAuth client ID for the daemon/agent.
285    pub client_id: String,
286    /// OAuth client secret for the daemon/agent.
287    pub client_secret: String,
288    /// NHI tenant ID — used for `X-Tenant-ID` header in `client_credentials` grant.
289    /// When set, `agent_token` is fetched and cached automatically; no static token needed.
290    pub tenant_id: Option<String>,
291    /// Static fallback agent token (`actor_token` in RFC 8693).
292    /// Used only when `tenant_id` is absent (backward-compat).
293    #[serde(default)]
294    pub agent_token: String,
295    /// OAuth scopes to request for the delegated token. Defaults to empty.
296    #[serde(default)]
297    pub scopes: Vec<String>,
298}
299
300/// Heartbit pulse configuration for autonomous periodic awareness.
301///
302/// When enabled, the daemon periodically reviews its persistent todo list
303/// and decides what to work on next — a cognitive pulse loop.
304#[derive(Debug, Clone, Deserialize)]
305pub struct HeartbitPulseConfig {
306    /// Enable the heartbit pulse. Defaults to `false`.
307    #[serde(default)]
308    pub enabled: bool,
309    /// Interval in seconds between heartbit pulse ticks. Defaults to 1800 (30 min).
310    #[serde(default = "default_pulse_interval")]
311    pub interval_seconds: u64,
312    /// Active hours window. When set, the pulse only fires within this window.
313    pub active_hours: Option<ActiveHoursConfig>,
314    /// Custom prompt override for the heartbit pulse. When absent, the
315    /// default built-in prompt is used.
316    pub prompt: Option<String>,
317    /// Number of consecutive HEARTBIT_OK responses before doubling the
318    /// interval (idle backoff). Defaults to 6 (3h at 30min interval).
319    #[serde(default = "default_idle_backoff_threshold")]
320    pub idle_backoff_threshold: u32,
321}
322
323fn default_pulse_interval() -> u64 {
324    1800
325}
326
327fn default_idle_backoff_threshold() -> u32 {
328    6
329}
330
331/// Active hours window for the heartbit pulse.
332#[derive(Debug, Clone, Deserialize)]
333pub struct ActiveHoursConfig {
334    /// Start time in "HH:MM" format (24-hour).
335    pub start: String,
336    /// End time in "HH:MM" format (24-hour).
337    pub end: String,
338}
339
340impl ActiveHoursConfig {
341    /// Parse the start hour and minute. Returns `(hour, minute)`.
342    pub fn parse_start(&self) -> Result<(u32, u32), Error> {
343        parse_hhmm(&self.start)
344    }
345
346    /// Parse the end hour and minute. Returns `(hour, minute)`.
347    pub fn parse_end(&self) -> Result<(u32, u32), Error> {
348        parse_hhmm(&self.end)
349    }
350}
351
352fn parse_hhmm(s: &str) -> Result<(u32, u32), Error> {
353    let parts: Vec<&str> = s.split(':').collect();
354    if parts.len() != 2 {
355        return Err(Error::Config(format!(
356            "invalid time format '{}': expected HH:MM",
357            s
358        )));
359    }
360    let hour: u32 = parts[0]
361        .parse()
362        .map_err(|_| Error::Config(format!("invalid hour in '{s}'")))?;
363    let minute: u32 = parts[1]
364        .parse()
365        .map_err(|_| Error::Config(format!("invalid minute in '{s}'")))?;
366    if hour > 23 || minute > 59 {
367        return Err(Error::Config(format!(
368            "time '{}' out of range (00:00-23:59)",
369            s
370        )));
371    }
372    Ok((hour, minute))
373}
374
375/// How the X-post pipeline produces the optional head-tweet image.
376#[cfg(feature = "ghost-domain-config")]
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
378#[serde(rename_all = "snake_case")]
379pub enum ImageSource {
380    /// Search Openverse for a CC0/public-domain image matching the post.
381    #[default]
382    Online,
383    /// Generate an image with the AI image tool.
384    Ai,
385    /// No image — text-only posts.
386    None,
387}
388
389/// Per-persona proactive-posting configuration.
390///
391/// When present, the daemon registers a `PersonaPostScheduler` that
392/// fires `DaemonCommand::PersonaPost` on the configured cadence
393/// (gated by `active_hours`). The handler runs the topic generator,
394/// drafts candidate threads via the existing review pipeline, sends
395/// them to Telegram for review, posts the chosen draft.
396///
397/// Configured under `[[daemon.persona_posts]]` blocks.
398#[cfg(feature = "ghost-domain-config")]
399#[derive(Debug, Clone, Deserialize)]
400pub struct PersonaPostsConfig {
401    /// Persona registry name (e.g. `"heartbit-ghost:x"`).
402    pub persona: String,
403    /// Whether this poster is enabled.
404    #[serde(default = "super::default_true")]
405    pub enabled: bool,
406    /// Posting interval, in seconds. Default 14400 (4 hours).
407    /// Recommended ≥60. NOTE: this is NOT enforced at config load — the value
408    /// is consumed as-is by the (cross-crate) scheduler loop.
409    #[serde(default = "default_post_interval_seconds")]
410    pub post_interval_seconds: u64,
411    /// Randomness applied to each `post_interval_seconds` tick, as a
412    /// percentage (`0`–`50`). Default `25` = ±25%, so a base 4h interval
413    /// fires somewhere in [3h, 5h]. Prevents the daemon from posting
414    /// on a perfectly predictable cadence — that's a textbook bot
415    /// signature on X. `0` disables jitter (use only for tests).
416    #[serde(default = "default_post_interval_jitter_pct")]
417    pub interval_jitter_pct: u32,
418    /// Optional `"HH:MM-HH:MM"` window during which posts are allowed.
419    /// Outside this window, the scheduler tick is a no-op. When absent,
420    /// posting is allowed 24/7.
421    #[serde(default)]
422    pub active_hours: Option<ActiveHoursConfig>,
423    /// Number of candidate threads to draft per tick (1..=10).
424    /// Default 3.
425    #[serde(default = "default_post_candidates")]
426    pub candidates_per_draft: usize,
427    /// Backend for the post history store: `"in_memory"` or `"jsonl"`.
428    #[serde(default = "default_post_history_store")]
429    pub post_history_store: String,
430    /// Path to the JSONL store file (only used when
431    /// `post_history_store == "jsonl"`). Tilde expansion happens at
432    /// store-construction time.
433    #[serde(default)]
434    pub post_history_path: Option<String>,
435    /// How far back to check for topic duplicates. Default 30 days.
436    #[serde(default = "default_post_history_lookback_days")]
437    pub post_history_lookback_days: i64,
438    /// Optional fallback brief used when the persona declares no
439    /// topic-context provider, or appended to the provider's output.
440    #[serde(default)]
441    pub topic_brief: Option<String>,
442
443    // --- Engagement feedback (P2.0) ---
444    /// Engagement-collector tick interval, in seconds. Default 21600 = 6h.
445    /// Each tick batch-refreshes every eligible Posted tweet's metrics
446    /// from the X API and writes a fresh [`EngagementSnapshot`].
447    #[serde(default = "default_engagement_refresh_seconds")]
448    pub engagement_refresh_seconds: u64,
449    /// How many top-engaged posts to inject as writer few-shot exemplars.
450    /// Default 5. Set to `0` to disable injection — the writer then runs
451    /// without exemplars, matching pre-P2.0 behavior.
452    #[serde(default = "default_engagement_top_n")]
453    pub engagement_top_n: usize,
454    /// Ignore tweets older than this many days when refreshing engagement.
455    /// Default 30 — older tweets rarely accrue new engagement.
456    #[serde(default = "default_engagement_max_age_days")]
457    pub engagement_max_age_days: i64,
458    /// Ignore tweets younger than this many hours when refreshing
459    /// engagement. Default 24 — gives the algorithm time to fan out.
460    #[serde(default = "default_engagement_min_age_hours")]
461    pub engagement_min_age_hours: i64,
462
463    /// Override LLM provider for the writer + style-critic stages of the
464    /// proactive-post pipeline. When set, these two "engagement voice"
465    /// agents use this provider; the researcher and fact-check stages
466    /// keep using the global `[provider]`. Use this to point the
467    /// engagement voice at a different model (e.g. Grok via OpenRouter)
468    /// without affecting verification or research quality.
469    ///
470    /// Configured under `[daemon.persona_posts.writer_provider]`. When
471    /// absent, the writer and critic share the global provider.
472    #[serde(default)]
473    pub writer_provider: Option<super::agent::AgentProviderConfig>,
474
475    /// How to produce the optional head-tweet image. Default `online`
476    /// (Openverse CC0/public-domain search). `ai` uses the image
477    /// generator; `none` disables images.
478    #[serde(default)]
479    pub image_source: ImageSource,
480}
481
482#[cfg(feature = "ghost-domain-config")]
483fn default_post_interval_seconds() -> u64 {
484    14400
485}
486
487#[cfg(feature = "ghost-domain-config")]
488fn default_post_interval_jitter_pct() -> u32 {
489    25
490}
491
492#[cfg(feature = "ghost-domain-config")]
493fn default_post_candidates() -> usize {
494    3
495}
496
497#[cfg(feature = "ghost-domain-config")]
498fn default_post_history_store() -> String {
499    "in_memory".into()
500}
501
502#[cfg(feature = "ghost-domain-config")]
503fn default_post_history_lookback_days() -> i64 {
504    30
505}
506
507#[cfg(feature = "ghost-domain-config")]
508fn default_engagement_refresh_seconds() -> u64 {
509    21600
510}
511
512#[cfg(feature = "ghost-domain-config")]
513fn default_engagement_top_n() -> usize {
514    5
515}
516
517#[cfg(feature = "ghost-domain-config")]
518fn default_engagement_max_age_days() -> i64 {
519    30
520}
521
522#[cfg(feature = "ghost-domain-config")]
523fn default_engagement_min_age_hours() -> i64 {
524    24
525}
526
527/// Per-persona quote-tweet configuration.
528///
529/// When present, the daemon registers a `PersonaQuoteScheduler` that
530/// fires `DaemonCommand::PersonaQuote` on the configured cadence. The
531/// handler polls each `source_user_ids` entry via X v2
532/// `/2/users/{id}/tweets`, filters by `max_age_hours` + not-yet-quoted,
533/// drafts an opinionated-but-charitable quote-tweet via the
534/// `quote_writer` agent, sends to Telegram for review, posts the
535/// chosen draft via `POST /2/tweets` with `quote_tweet_id`.
536///
537/// Configured under `[[daemon.persona_quotes]]` blocks.
538#[cfg(feature = "ghost-domain-config")]
539#[derive(Debug, Clone, Deserialize)]
540pub struct PersonaQuotesConfig {
541    /// Persona registry name (e.g. `"heartbit-ghost:x"`).
542    pub persona: String,
543    /// Whether this quoter is enabled.
544    #[serde(default = "super::default_true")]
545    pub enabled: bool,
546    /// Polling interval, in seconds. Default 5400 (90 min — medium per
547    /// brainstorm).
548    /// Recommended ≥60. NOTE: this is NOT enforced at config load — the value
549    /// is consumed as-is by the (cross-crate) scheduler loop.
550    #[serde(default = "default_quote_poll_interval_seconds")]
551    pub poll_interval_seconds: u64,
552    /// Randomness applied to each `poll_interval_seconds` tick, as a
553    /// percentage (`0`–`50`). Default `25` = ±25%. Same anti-pattern
554    /// rationale as `interval_jitter_pct` on `persona_posts`.
555    #[serde(default = "default_quote_interval_jitter_pct")]
556    pub interval_jitter_pct: u32,
557    /// Optional `"HH:MM-HH:MM"` window during which quote-polls run.
558    /// Outside this window, the scheduler tick is a no-op.
559    #[serde(default)]
560    pub active_hours: Option<ActiveHoursConfig>,
561    /// X user IDs (numeric strings) of accounts to poll. Curated list.
562    /// Required — must contain at least one entry.
563    pub source_user_ids: Vec<String>,
564    /// Number of candidate quote-tweets to draft per chosen source
565    /// tweet (1..=3). Default 3. Bounded tighter than the proactive
566    /// post pipeline (1..=10) because quote-tweets are higher-stakes
567    /// (you're commenting on someone else's content) and 3 candidates
568    /// hits the cost/quality knee.
569    #[serde(default = "default_quote_candidates_per_draft")]
570    pub candidates_per_draft: usize,
571    /// Backend for the "already quoted" dedup store: `"in_memory"` or
572    /// `"jsonl"`. Default `"in_memory"`.
573    #[serde(default = "default_quote_seen_store")]
574    pub seen_store: String,
575    /// Path to the JSONL store file (only used when
576    /// `seen_store == "jsonl"`). Tilde expansion at construction time.
577    #[serde(default)]
578    pub seen_store_path: Option<String>,
579    /// Maximum age in hours of a source tweet for it to be quote-able.
580    /// Default 12 — beyond that the discourse has moved on. Set to 0
581    /// to disable the age filter.
582    #[serde(default = "default_quote_max_age_hours")]
583    pub max_age_hours: i64,
584    /// Maximum number of quote-tweets to draft+review per scheduler
585    /// tick. Default 1 — pick the best candidate from sources and
586    /// stop. Set higher to draft multiple quote-tweets per tick
587    /// (one Telegram review per draft).
588    #[serde(default = "default_quote_max_candidates_per_tick")]
589    pub max_candidates_per_tick: usize,
590    /// Optional override LLM provider for the quote_writer + critic.
591    /// `None` falls back to the global `[provider]`. Same shape as
592    /// `persona_posts.writer_provider`.
593    #[serde(default)]
594    pub writer_provider: Option<super::agent::AgentProviderConfig>,
595}
596
597#[cfg(feature = "ghost-domain-config")]
598fn default_quote_poll_interval_seconds() -> u64 {
599    5400 // 90 minutes
600}
601
602#[cfg(feature = "ghost-domain-config")]
603fn default_quote_interval_jitter_pct() -> u32 {
604    25
605}
606
607#[cfg(feature = "ghost-domain-config")]
608fn default_quote_candidates_per_draft() -> usize {
609    3
610}
611
612#[cfg(feature = "ghost-domain-config")]
613fn default_quote_seen_store() -> String {
614    "in_memory".into()
615}
616
617#[cfg(feature = "ghost-domain-config")]
618fn default_quote_max_age_hours() -> i64 {
619    12
620}
621
622#[cfg(feature = "ghost-domain-config")]
623fn default_quote_max_candidates_per_tick() -> usize {
624    1
625}
626
627/// Personal-blog publishing configuration.
628///
629/// When present, the daemon registers a `PersonaBlogScheduler` that
630/// fires `DaemonCommand::PersonaBlog` on a weekly cadence (with jitter).
631/// The handler picks the highest-engagement X post from the prior
632/// `seed_lookback_days` (default 7) as the topic seed, drafts a
633/// long-form essay via the `blog_writer` agent, routes through
634/// Telegram for review, writes the picked draft as Markdown to
635/// `posts_dir`, and re-renders the static site into `out_dir`.
636///
637/// Configured under `[daemon.persona_blog]` (single block, not a
638/// list — one blog per daemon).
639#[cfg(feature = "ghost-domain-config")]
640#[derive(Debug, Clone, Deserialize)]
641pub struct PersonaBlogConfig {
642    /// Persona registry name (e.g. `"heartbit-ghost:x"`). Must match an
643    /// existing persona whose post history + engagement store are
644    /// already configured under `[[daemon.persona_posts]]` for the same
645    /// slug — the blog reuses those stores for seed selection.
646    pub persona: String,
647    /// Whether the blog scheduler is enabled.
648    #[serde(default = "super::default_true")]
649    pub enabled: bool,
650    /// Polling interval in seconds. Default 604_800 (7 days = weekly).
651    /// Validation: must be ≥3600 (1 hour) — anything tighter is almost
652    /// certainly a misconfig and produces thin posts.
653    #[serde(default = "default_blog_poll_interval_seconds")]
654    pub poll_interval_seconds: u64,
655    /// Jitter percentage applied to the cadence. Default 10 — tighter
656    /// than X posts because weekly is already coarse and operators
657    /// usually want a predictable day-of-week.
658    #[serde(default = "default_blog_interval_jitter_pct")]
659    pub interval_jitter_pct: u32,
660    /// Optional active-hours window for the scheduler.
661    #[serde(default)]
662    pub active_hours: Option<ActiveHoursConfig>,
663    /// Directory holding the generated Markdown post files. Relative to
664    /// the daemon's CWD; tilde-expanded.
665    /// Default: `"blog-site/posts"`.
666    #[serde(default = "default_blog_posts_dir")]
667    pub posts_dir: String,
668    /// Directory where the rendered static site is written. Relative to
669    /// CWD; tilde-expanded. This is what gets deployed to Cloudflare
670    /// Pages (commit + push triggers deploy).
671    /// Default: `"blog-site/public"`.
672    #[serde(default = "default_blog_out_dir")]
673    pub out_dir: String,
674    /// How many days back to look for the highest-engagement X post
675    /// used as the topic seed. Default 7. Set to 0 to disable
676    /// X-derived seeding (forces the operator to set `topic_brief`).
677    #[serde(default = "default_blog_seed_lookback_days")]
678    pub seed_lookback_days: i64,
679    /// Number of candidate essay drafts per tick. Default 2 — long-form
680    /// drafts are expensive (~3-5k tokens each); 2 is enough for
681    /// meaningful comparison without blowing the budget.
682    #[serde(default = "default_blog_candidates_per_draft")]
683    pub candidates_per_draft: usize,
684    /// Public site URL (required) — used to build canonical URLs,
685    /// OpenGraph tags, the sitemap, and the RSS feed.
686    pub site_url: String,
687    /// Site title rendered in `<title>` and the index page header.
688    /// Default: `"pascal.heartbit.ai"`.
689    #[serde(default = "default_blog_site_title")]
690    pub site_title: String,
691    /// Optional override LLM provider for the blog_writer + critic.
692    /// `None` falls back to the global `[provider]`. Same shape as
693    /// `persona_posts.writer_provider`.
694    #[serde(default)]
695    pub writer_provider: Option<super::agent::AgentProviderConfig>,
696    /// Optional shell command run after a successful `BlogOutcome::Posted`.
697    /// Runs from the daemon CWD; env is inherited (use this with
698    /// `CLOUDFLARE_API_TOKEN` exported in the daemon shell to invoke
699    /// `wrangler pages deploy …`). Empty/`None` skips the hook.
700    #[serde(default)]
701    pub deploy_command: Option<String>,
702    /// Optional X self-amplification — when `Some(cfg)` and `cfg.enabled`,
703    /// each successful blog publish (after deploy_command succeeds or is
704    /// absent) enqueues a `DaemonCommand::BlogAnnounceX` that drafts an
705    /// announcement thread through the existing X persona pipeline +
706    /// Telegram review.
707    #[serde(default)]
708    pub x_announce: Option<XAnnounceConfig>,
709    /// Optional GitHub Profile README auto-update — when `Some(cfg)` and
710    /// `cfg.enabled`, each successful blog publish re-renders the
711    /// operator's profile README to feature the 3 most recent essays
712    /// and pushes the change to GitHub via the local repo clone.
713    #[serde(default)]
714    pub github_readme: Option<GithubReadmeConfig>,
715}
716
717#[cfg(feature = "ghost-domain-config")]
718fn default_blog_poll_interval_seconds() -> u64 {
719    604_800 // 7 days
720}
721
722#[cfg(feature = "ghost-domain-config")]
723fn default_blog_interval_jitter_pct() -> u32 {
724    10
725}
726
727#[cfg(feature = "ghost-domain-config")]
728fn default_blog_posts_dir() -> String {
729    "blog-site/posts".into()
730}
731
732#[cfg(feature = "ghost-domain-config")]
733fn default_blog_out_dir() -> String {
734    "blog-site/public".into()
735}
736
737#[cfg(feature = "ghost-domain-config")]
738fn default_blog_seed_lookback_days() -> i64 {
739    7
740}
741
742#[cfg(feature = "ghost-domain-config")]
743fn default_blog_candidates_per_draft() -> usize {
744    2
745}
746
747#[cfg(feature = "ghost-domain-config")]
748fn default_blog_site_title() -> String {
749    "pascal.heartbit.ai".into()
750}
751
752/// X self-amplification settings (sub-block of `[daemon.persona_blog]`).
753#[cfg(feature = "ghost-domain-config")]
754#[derive(Debug, Clone, Deserialize)]
755pub struct XAnnounceConfig {
756    /// Whether X self-amplification is enabled.
757    #[serde(default = "super::default_true")]
758    pub enabled: bool,
759}
760
761/// GitHub Profile README auto-update settings.
762#[cfg(feature = "ghost-domain-config")]
763#[derive(Debug, Clone, Deserialize)]
764pub struct GithubReadmeConfig {
765    /// Whether the GitHub README update is enabled.
766    #[serde(default = "super::default_true")]
767    pub enabled: bool,
768    /// Absolute path to the local clone of the profile repo
769    /// (e.g. `/home/pleclech/projects/100-tokens-profile`). Must already
770    /// be cloned with `origin` configured for `git push` (HTTPS token or
771    /// SSH key).
772    pub local_repo_path: String,
773    /// Path (absolute or relative to `local_repo_path`) to the operator-
774    /// authored bio template inserted at the top of the README. If the
775    /// file is missing, a minimal default is used.
776    #[serde(default = "default_bio_template_path")]
777    pub bio_template_path: String,
778    /// Git commit author name.
779    pub git_author_name: String,
780    /// Git commit author email.
781    pub git_author_email: String,
782}
783
784#[cfg(feature = "ghost-domain-config")]
785fn default_bio_template_path() -> String {
786    "bio.md".into()
787}
788
789/// WebSocket configuration for bidirectional user↔agent communication.
790#[derive(Debug, Clone, Deserialize)]
791pub struct WsConfig {
792    /// Whether WebSocket endpoint is enabled. Defaults to `true`.
793    #[serde(default = "super::default_true")]
794    pub enabled: bool,
795    /// Timeout in seconds for blocking interactions (approval, input, question).
796    /// Defaults to 120 seconds.
797    #[serde(default = "default_interaction_timeout")]
798    pub interaction_timeout_seconds: u64,
799    /// Maximum concurrent WebSocket connections. Defaults to 100.
800    #[serde(default = "default_max_ws_connections")]
801    pub max_connections: usize,
802    /// PostgreSQL URL for durable session persistence. When absent, sessions
803    /// are stored in-memory (lost on restart).
804    #[serde(default)]
805    pub database_url: Option<String>,
806}
807
808fn default_interaction_timeout() -> u64 {
809    120
810}
811
812fn default_max_ws_connections() -> usize {
813    100
814}
815
816/// Kafka broker connection settings.
817#[derive(Debug, Clone, Deserialize)]
818pub struct KafkaConfig {
819    /// Comma-separated bootstrap broker list (e.g. `localhost:9092`).
820    pub brokers: String,
821    /// Consumer group ID for the daemon's command consumer.
822    #[serde(default = "default_consumer_group")]
823    pub consumer_group: String,
824    /// Topic the daemon consumes `DaemonCommand` messages from.
825    #[serde(default = "default_commands_topic")]
826    pub commands_topic: String,
827    /// Topic the daemon emits `AgentEvent` records to.
828    #[serde(default = "default_events_topic")]
829    pub events_topic: String,
830    /// Topic for events that failed triage processing.
831    #[serde(default = "default_dead_letter_topic")]
832    pub dead_letter_topic: String,
833}
834
835/// A scheduled task entry for the cron scheduler.
836#[derive(Debug, Clone, Deserialize)]
837pub struct ScheduleEntry {
838    /// Human-readable schedule name (used in logs/metrics).
839    pub name: String,
840    /// Cron expression (5- or 6-field, depending on parser).
841    pub cron: String,
842    /// Task body to submit when the schedule fires.
843    pub task: String,
844    /// Set to `false` to keep the entry declared but inactive.
845    #[serde(default = "super::default_true")]
846    pub enabled: bool,
847}
848
849/// Prometheus metrics configuration for daemon mode.
850#[derive(Debug, Clone, Deserialize)]
851pub struct MetricsConfig {
852    /// Whether Prometheus metrics are enabled. Defaults to `true`.
853    #[serde(default = "super::default_true")]
854    pub enabled: bool,
855}
856
857fn default_daemon_bind() -> String {
858    "127.0.0.1:3000".into()
859}
860
861fn default_max_concurrent() -> usize {
862    4
863}
864
865fn default_consumer_group() -> String {
866    "heartbit-daemon".into()
867}
868
869fn default_commands_topic() -> String {
870    "heartbit.commands".into()
871}
872
873fn default_events_topic() -> String {
874    "heartbit.events".into()
875}
876
877fn default_dead_letter_topic() -> String {
878    "heartbit.dead-letter".into()
879}
880
881#[cfg(all(test, feature = "ghost-domain-config"))]
882mod tests {
883    use super::*;
884
885    #[test]
886    fn persona_posts_config_parses_with_defaults() {
887        let toml = r#"
888[[persona_posts]]
889persona = "heartbit-ghost:x"
890"#;
891        #[derive(Deserialize)]
892        struct Shim {
893            persona_posts: Vec<PersonaPostsConfig>,
894        }
895        let cfg: Shim = toml::from_str(toml).unwrap();
896        assert_eq!(cfg.persona_posts.len(), 1);
897        let p = &cfg.persona_posts[0];
898        assert_eq!(p.persona, "heartbit-ghost:x");
899        assert!(p.enabled);
900        assert_eq!(p.post_interval_seconds, 14400);
901        assert_eq!(p.candidates_per_draft, 3);
902        assert_eq!(p.post_history_store, "in_memory");
903        assert_eq!(p.post_history_lookback_days, 30);
904        assert!(p.active_hours.is_none());
905        assert!(p.topic_brief.is_none());
906        assert!(p.post_history_path.is_none());
907        // Engagement defaults (P2.0).
908        assert_eq!(p.engagement_refresh_seconds, 21600);
909        assert_eq!(p.engagement_top_n, 5);
910        assert_eq!(p.engagement_max_age_days, 30);
911        assert_eq!(p.engagement_min_age_hours, 24);
912        // Writer provider override defaults to None.
913        assert!(p.writer_provider.is_none());
914    }
915
916    #[test]
917    fn persona_posts_config_parses_writer_provider_override() {
918        let toml = r#"
919[[persona_posts]]
920persona = "heartbit-ghost:x"
921
922[persona_posts.writer_provider]
923name = "openrouter"
924model = "x-ai/grok-4"
925"#;
926        #[derive(Deserialize)]
927        struct Shim {
928            persona_posts: Vec<PersonaPostsConfig>,
929        }
930        let cfg: Shim = toml::from_str(toml).unwrap();
931        let p = &cfg.persona_posts[0];
932        let wp = p.writer_provider.as_ref().expect("writer_provider present");
933        assert_eq!(wp.name, "openrouter");
934        assert_eq!(wp.model, "x-ai/grok-4");
935    }
936
937    #[test]
938    fn persona_posts_config_parses_engagement_overrides() {
939        let toml = r#"
940[[persona_posts]]
941persona = "heartbit-ghost:x"
942engagement_refresh_seconds = 3600
943engagement_top_n = 0
944engagement_max_age_days = 7
945engagement_min_age_hours = 6
946"#;
947        #[derive(Deserialize)]
948        struct Shim {
949            persona_posts: Vec<PersonaPostsConfig>,
950        }
951        let cfg: Shim = toml::from_str(toml).unwrap();
952        let p = &cfg.persona_posts[0];
953        assert_eq!(p.engagement_refresh_seconds, 3600);
954        assert_eq!(p.engagement_top_n, 0);
955        assert_eq!(p.engagement_max_age_days, 7);
956        assert_eq!(p.engagement_min_age_hours, 6);
957    }
958
959    #[test]
960    fn persona_posts_image_source_defaults_to_online() {
961        let toml = r#"
962[[persona_posts]]
963persona = "heartbit-ghost:x"
964"#;
965        #[derive(Deserialize)]
966        struct Shim {
967            persona_posts: Vec<PersonaPostsConfig>,
968        }
969        let cfg: Shim = toml::from_str(toml).unwrap();
970        assert_eq!(cfg.persona_posts[0].image_source, ImageSource::Online);
971    }
972
973    #[test]
974    fn persona_posts_image_source_parses_explicit() {
975        let toml = r#"
976[[persona_posts]]
977persona = "heartbit-ghost:x"
978image_source = "none"
979"#;
980        #[derive(Deserialize)]
981        struct Shim {
982            persona_posts: Vec<PersonaPostsConfig>,
983        }
984        let cfg: Shim = toml::from_str(toml).unwrap();
985        assert_eq!(cfg.persona_posts[0].image_source, ImageSource::None);
986    }
987
988    #[test]
989    fn persona_posts_image_source_parses_ai() {
990        let toml = r#"
991[[persona_posts]]
992persona = "heartbit-ghost:x"
993image_source = "ai"
994"#;
995        #[derive(Deserialize)]
996        struct Shim {
997            persona_posts: Vec<PersonaPostsConfig>,
998        }
999        let cfg: Shim = toml::from_str(toml).unwrap();
1000        assert_eq!(cfg.persona_posts[0].image_source, ImageSource::Ai);
1001    }
1002
1003    #[test]
1004    fn persona_quotes_config_parses_with_defaults() {
1005        let toml = r#"
1006[[persona_quotes]]
1007persona = "heartbit-ghost:x"
1008source_user_ids = ["44196397", "16884623"]
1009"#;
1010        #[derive(Deserialize)]
1011        struct Shim {
1012            persona_quotes: Vec<PersonaQuotesConfig>,
1013        }
1014        let cfg: Shim = toml::from_str(toml).unwrap();
1015        assert_eq!(cfg.persona_quotes.len(), 1);
1016        let q = &cfg.persona_quotes[0];
1017        assert_eq!(q.persona, "heartbit-ghost:x");
1018        assert!(q.enabled);
1019        assert_eq!(q.poll_interval_seconds, 5400); // default 90 min
1020        assert_eq!(q.interval_jitter_pct, 25);
1021        assert!(q.active_hours.is_none());
1022        assert_eq!(q.candidates_per_draft, 3);
1023        assert_eq!(q.seen_store, "in_memory");
1024        assert!(q.seen_store_path.is_none());
1025        assert_eq!(q.max_age_hours, 12);
1026        assert_eq!(q.max_candidates_per_tick, 1);
1027        assert_eq!(q.source_user_ids, vec!["44196397", "16884623"]);
1028        assert!(q.writer_provider.is_none());
1029    }
1030
1031    #[test]
1032    fn persona_quotes_config_rejects_missing_required_fields() {
1033        let toml = r#"
1034[[persona_quotes]]
1035persona = "heartbit-ghost:x"
1036"#;
1037        // source_user_ids is required (no default). Parse must fail.
1038        #[derive(Debug, Deserialize)]
1039        struct Shim {
1040            #[allow(dead_code)]
1041            persona_quotes: Vec<PersonaQuotesConfig>,
1042        }
1043        let err = toml::from_str::<Shim>(toml).unwrap_err();
1044        assert!(
1045            err.to_string().contains("source_user_ids"),
1046            "expected missing-field error for source_user_ids; got: {err}"
1047        );
1048    }
1049
1050    #[test]
1051    fn persona_blog_config_parses_with_defaults() {
1052        let toml = r#"
1053[persona_blog]
1054persona = "heartbit-ghost:x"
1055site_url = "https://pascal.heartbit.ai"
1056"#;
1057        #[derive(Deserialize)]
1058        struct Shim {
1059            persona_blog: PersonaBlogConfig,
1060        }
1061        let cfg: Shim = toml::from_str(toml).unwrap();
1062        let b = &cfg.persona_blog;
1063        assert_eq!(b.persona, "heartbit-ghost:x");
1064        assert!(b.enabled);
1065        assert_eq!(b.poll_interval_seconds, 604_800); // 7 days
1066        assert_eq!(b.interval_jitter_pct, 10);
1067        assert_eq!(b.posts_dir, "blog-site/posts");
1068        assert_eq!(b.out_dir, "blog-site/public");
1069        assert_eq!(b.seed_lookback_days, 7);
1070        assert_eq!(b.candidates_per_draft, 2);
1071        assert_eq!(b.site_url, "https://pascal.heartbit.ai");
1072        assert_eq!(b.site_title, "pascal.heartbit.ai");
1073        assert!(b.writer_provider.is_none());
1074        assert!(b.deploy_command.is_none());
1075    }
1076
1077    #[test]
1078    fn persona_blog_config_parses_deploy_command() {
1079        let toml = r#"
1080[persona_blog]
1081persona = "heartbit-ghost:x"
1082site_url = "https://pascal.heartbit.ai"
1083deploy_command = "npx wrangler pages deploy blog-site/public --project-name=pascal-heartbit-ai --commit-dirty=true --branch=main"
1084"#;
1085        #[derive(Deserialize)]
1086        struct Shim {
1087            persona_blog: PersonaBlogConfig,
1088        }
1089        let cfg: Shim = toml::from_str(toml).unwrap();
1090        let cmd = cfg.persona_blog.deploy_command.as_deref().unwrap();
1091        assert!(cmd.contains("wrangler pages deploy"));
1092        assert!(cmd.contains("pascal-heartbit-ai"));
1093    }
1094
1095    #[test]
1096    fn persona_blog_config_parses_x_announce_block() {
1097        let toml = r#"
1098[persona_blog]
1099persona = "heartbit-ghost:x"
1100site_url = "https://pascal.heartbit.ai"
1101
1102[persona_blog.x_announce]
1103enabled = true
1104"#;
1105        #[derive(Deserialize)]
1106        struct Shim {
1107            persona_blog: PersonaBlogConfig,
1108        }
1109        let cfg: Shim = toml::from_str(toml).unwrap();
1110        let x = cfg.persona_blog.x_announce.as_ref().unwrap();
1111        assert!(x.enabled);
1112    }
1113
1114    #[test]
1115    fn persona_blog_config_parses_github_readme_block() {
1116        let toml = r#"
1117[persona_blog]
1118persona = "heartbit-ghost:x"
1119site_url = "https://pascal.heartbit.ai"
1120
1121[persona_blog.github_readme]
1122enabled = true
1123local_repo_path = "/home/pleclech/projects/100-tokens-profile"
1124git_author_name = "Pascal Le Clech"
1125git_author_email = "pascal@heartbit.ai"
1126"#;
1127        #[derive(Deserialize)]
1128        struct Shim {
1129            persona_blog: PersonaBlogConfig,
1130        }
1131        let cfg: Shim = toml::from_str(toml).unwrap();
1132        let gh = cfg.persona_blog.github_readme.as_ref().unwrap();
1133        assert!(gh.enabled);
1134        assert_eq!(
1135            gh.local_repo_path,
1136            "/home/pleclech/projects/100-tokens-profile"
1137        );
1138        assert_eq!(gh.bio_template_path, "bio.md"); // default
1139        assert_eq!(gh.git_author_name, "Pascal Le Clech");
1140    }
1141
1142    #[test]
1143    fn persona_blog_config_amp_blocks_default_to_none() {
1144        let toml = r#"
1145[persona_blog]
1146persona = "heartbit-ghost:x"
1147site_url = "https://pascal.heartbit.ai"
1148"#;
1149        #[derive(Deserialize)]
1150        struct Shim {
1151            persona_blog: PersonaBlogConfig,
1152        }
1153        let cfg: Shim = toml::from_str(toml).unwrap();
1154        assert!(cfg.persona_blog.x_announce.is_none());
1155        assert!(cfg.persona_blog.github_readme.is_none());
1156    }
1157
1158    #[test]
1159    fn persona_blog_config_rejects_missing_required_fields() {
1160        let toml = r#"
1161[persona_blog]
1162persona = "heartbit-ghost:x"
1163"#;
1164        // site_url is required (no default). Parse must fail.
1165        #[derive(Deserialize, Debug)]
1166        struct Shim {
1167            #[allow(dead_code)]
1168            persona_blog: PersonaBlogConfig,
1169        }
1170        let err = toml::from_str::<Shim>(toml).unwrap_err();
1171        assert!(
1172            err.to_string().contains("site_url"),
1173            "expected missing-field error for site_url; got: {err}"
1174        );
1175    }
1176
1177    #[test]
1178    fn persona_posts_config_parses_full() {
1179        let toml = r#"
1180[[persona_posts]]
1181persona = "heartbit-ghost:x"
1182enabled = true
1183post_interval_seconds = 7200
1184candidates_per_draft = 5
1185post_history_store = "jsonl"
1186post_history_path = "~/.heartbit/posts.jsonl"
1187post_history_lookback_days = 14
1188topic_brief = "agents, Rust, LLMs"
1189
1190[persona_posts.active_hours]
1191start = "09:00"
1192end = "22:00"
1193"#;
1194        #[derive(Deserialize)]
1195        struct Shim {
1196            persona_posts: Vec<PersonaPostsConfig>,
1197        }
1198        let cfg: Shim = toml::from_str(toml).unwrap();
1199        let p = &cfg.persona_posts[0];
1200        assert_eq!(p.persona, "heartbit-ghost:x");
1201        assert!(p.enabled);
1202        assert_eq!(p.post_interval_seconds, 7200);
1203        assert_eq!(p.candidates_per_draft, 5);
1204        assert_eq!(p.post_history_store, "jsonl");
1205        assert_eq!(
1206            p.post_history_path.as_deref(),
1207            Some("~/.heartbit/posts.jsonl")
1208        );
1209        assert_eq!(p.post_history_lookback_days, 14);
1210        assert_eq!(p.topic_brief.as_deref(), Some("agents, Rust, LLMs"));
1211        assert!(p.active_hours.is_some());
1212        let h = p.active_hours.as_ref().unwrap();
1213        assert_eq!(h.start, "09:00");
1214        assert_eq!(h.end, "22:00");
1215    }
1216}