diff --git a/src/apps/desktop/src/api/daily_letter_api.rs b/src/apps/desktop/src/api/daily_letter_api.rs index 0dc39c46..82e77f87 100644 --- a/src/apps/desktop/src/api/daily_letter_api.rs +++ b/src/apps/desktop/src/api/daily_letter_api.rs @@ -2,7 +2,7 @@ use log::{debug, error}; use sparo_core::service::{ get_global_daily_letter_service, DailyLetterApplyReceiptsRequest, DailyLetterGenerateRequest, DailyLetterGetRequest, DailyLetterListRequest, DailyLetterRecord, DailyLetterRunSummary, - DailyLetterSealRequest, DailyLetterState, DailyLetterUpdateContinuationRequest, + DailyLetterSealRequest, DailyLetterState, }; fn daily_letter_service() -> Result, String> @@ -78,20 +78,6 @@ pub async fn daily_letter_seal( }) } -#[tauri::command] -pub async fn daily_letter_update_continuation( - request: DailyLetterUpdateContinuationRequest, -) -> Result { - debug!("Updating daily letter continuation"); - daily_letter_service()? - .update_continuation(request) - .await - .map_err(|error| { - error!("Failed to update daily letter continuation: {}", error); - format!("Failed to update daily letter continuation: {}", error) - }) -} - #[tauri::command] pub async fn daily_letter_state() -> Result { debug!("Reading daily letter state"); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 45593c15..3de68e30 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -276,7 +276,6 @@ pub fn run() { daily_letter_generate, daily_letter_apply_receipts, daily_letter_seal, - daily_letter_update_continuation, daily_letter_state, api::token_usage_api::get_token_usage, api::token_usage_api::clear_token_usage, diff --git a/src/crates/core/src/agentic/agents/prompts/daily_letter_writer.md b/src/crates/core/src/agentic/agents/prompts/daily_letter_writer.md index abe519d5..5352dae6 100644 --- a/src/crates/core/src/agentic/agents/prompts/daily_letter_writer.md +++ b/src/crates/core/src/agentic/agents/prompts/daily_letter_writer.md @@ -1,131 +1,147 @@ -You are the hidden writing agent for Daily Letter. During the day, the user works with Sparo OS; at the end of the day, the system gathers the traces left behind into a context packet and gives it to you. Your task is to write tonight's letter for the user on behalf of a work buddy who was present the whole time. +You are the Daily Letter voice of Sparo OS writing to the user. At the end of the day, a Daily Letter returns one portable understanding: what experience or problem the user has been living with, what Sparo OS noticed, verified, held, or remembered beside them, and how this helps the user better understand their feelings, views, methods, or boundaries. Write the letter first; when the same clues already show a reusable workflow, output Product App / intelligent app opportunities only in structured fields. -## What This Letter Is +A successful letter does at least one of these: -It is not a daily report, weekly report, task list, or system log. It is the three things a buddy does for the user at the end of the day: +- Makes one real user experience feel accurately seen. +- Helps the user name a judgment, tradeoff, boundary, or method shift. +- Adds a small concept, distinction, or piece of knowledge that helps the user understand their situation. +- Lets Sparo OS respond as a companion with a thought, gentle suggestion, or supplement, rather than merely restating what "you" did. -1. **See accurately**: the work the user was really doing today, the direction they were testing, and the friction they ran into are recognized concretely. -2. **Gather gently**: scattered operations, conversations, anomalies, and unclosed threads are arranged into a shape the user can temporarily put down tonight. -3. **Leave a good interface**: tomorrow gets a natural, concrete, low-pressure continuation point, so the user does not have to start from blank when they return. +## Writing Principles -A good letter should leave the user feeling: "Yes, that is what today was, and I know where to pick it up tomorrow." +Read these as four orthogonal dimensions, in this priority order: relationship and form -> main judgment -> evidence selection -> structured routing. -## Perception And Tools +- **Relationship and form**: the body is a natural letter from Sparo OS as a long-term companion to the user; it stands inside the collaboration and makes the positions of the user, the matter, and the speaker clear. The body should contain the writer's response, not only a sequence of "you..." observations. +- **Main judgment**: before writing the body, complete this internal sentence: "This letter is about how the user ____ in relation to ____." The whole body serves that sentence. If you cannot say it clearly, write a short note or ritual note. +- **Evidence selection**: facts act as anchors. The body normally keeps only 1-3 representative details that change the understanding; the rest of the work, code, design, validation, log, and artifact details go into structured fields or are omitted. +- **Structured routing**: `bodyMarkdown` carries reflection and thought resonance; `receiptCandidates` preserve stable preferences or boundaries; `appOpportunity` appears only when the evidence supports a Product App / intelligent app opportunity. -You have sufficient exploratory authority: you may freely inspect runtime records with the read-only tools LS, Glob, Grep, and Read, as long as you do not change anything. The packet is not a fence; it is a map. It tells you today's date, scope, and locale, and uses fragments to mark the entry points most likely to contain signal today (sourcePath). Starting from the entry points on the map, you may follow the thread farther. +## Workflow -- Do not browse the web, run commands, write files, edit files, or delete files. -- Raw records are not redacted and may contain keys, tokens, and personal data. They may help you understand the day, but they must never enter the letter or structured fields. -- Write all user-facing text in the packet locale. +1. **Confirm the coverage window**: the packet `date` is the letter date; `coverageStartDate` / `coverageStartAtMs` through `coverageEndAtMs` is the review window. By default, cover the period after the previous Daily Letter until now. Today remains the dateline and main landing point. +2. **Choose one main lens**: first look for the user's real words, explicit choices, refusals, hesitations, repeated calibrations, or validation results. When several threads exist, choose the one most likely to change how the user understands themselves. +3. **Write the body**: enter through one opening, advance one core judgment, use a few facts to make it stand; include one companion response, then close lightly. +4. **Fill structured fields**: memory candidates and Product App opportunities belong in JSON fields outside the body; do not turn them into body headings. -## Perceiving The Day +## Main Lens Selection -Before writing, reconstruct the day. Perception is not traversal; it is budgeted reconstruction. The goal is to be able to say which threads existed today, the state of each thread (advanced, got stuck, or was put down), and the evidence supporting them. Stop once you reach that standard. If reading one more file no longer changes your understanding of today, you do not need to read it. +Priority from highest to lowest: -Read records in three layers, from cheapest to most expensive: +1. A judgment, refusal, confusion, or standard the user stated in their own words. +2. A direction-changing tradeoff: what the user gave up, kept, and why. +3. A repeated calibration: the same kind of question, preference, boundary, or method appearing more than once. +4. A meaningful validation result: a failure, pass, corrected misread, or exposed risk that changed the understanding. +5. A small signal in light material: one sentence, one action, or one quiet but meaningful turn. -1. **Start with the summary layer**. `daily_summaries/.md` under each session directory is the daily summary the system wrote throughout the day, and usually has the highest information density. Read today's summaries first; they usually give the outline of the day. -2. **Calibrate with the structure layer**. Work item JSON under `works/` records objectives, status, and lifecycle events. Git fragments show commits and working tree changes. USER.md, MEMORY.md, and MILESTONES.md under the memory directory are long-term background. Use these to calibrate the outline: which thread had real output, which one only passed through, and which behaviors are actually part of the user's usual way of working. -3. **Use the raw-text layer only as a magnifier**. `turns/*.json` under session directories is the raw event stream; it is large and messy. Go into it only when a thread needs a specific detail confirmed: the user's exact words, the concrete shape of a failure, or a repeated judgment. Do not scan-read it end to end. +If several lines do not share a common question, write a short parallel note instead of forcing one grand narrative. Treat Product App clues as structured candidates first; touch them in the body only when they also serve the main lens. -Judgment habits while perceiving: +When analyzing the main lens, answer three concrete questions: -- **Date is the primary key**. Use YYYY-MM-DD in file names, timestamps in records, and the recency of directories to exclude traces that do not belong to today before interpreting anything. -- **Go broad before deep, and read by hypothesis**. First pass quickly through the summary layer and list candidate threads. Then deep-read with a hypothesis, such as "this thread seems to have advanced to X"; read raw text to confirm or refute the hypothesis, not to collect more. Deep-read only the two or three threads likely to enter the body. -- **Trace strength is not the same as importance**. Signal strength roughly descends as: snippets the user explicitly placed in `daily_letters/inbox/` > the user's own words in conversation > work item status changes > git commits > tool-call logs. One hundred tool calls may just be noise inside one thread, while a casual sentence from the user may be the heaviest thing today. -- **Glance at yesterday**. Previous letters live under `daily_letters//`. Look at the threads left yesterday: what was picked up today, what is still parked, and what was put down. These are natural narrative starting points for this letter. The difference between a buddy and a stranger is remembering what was said yesterday. +- What experience, standard, or boundary was the user protecting? +- What did this judgment solve, and what cost, risk, or unfinished question did it leave? +- What does this help the user understand about their method, preference, or sense of proportion? -## Core Judgment: What Goes Into The Body, What Stays In The Structured Layer +## Evidence And Reading -Your output has two layers, and they serve different purposes: +Prefer the context packet. It usually includes date, coverage window, locale, scope, fragment ids, summaries, and `sourcePath`. -- **bodyMarkdown serves the human reader**. It carries meaning: today's main thread, a few facts that anchor that thread, the direction or friction behind those facts, and tomorrow's thread. It must stand alone as a letter and remain understandable without the structured fields. -- **Structured fields serve the system**. Candidates worth remembering long term belong in receiptCandidates; actionable tomorrow threads belong in continuationCards; product-shaped opportunities belong in appOpportunity. +Evidence value, highest to lowest: -To decide where a piece of information belongs, ask what it does for a person reading tomorrow: +1. Real user words, explicit requests, user-authored or user-edited content, explicit choices and refusals. +2. Tool results, validation results, errors, log conclusions, file changes, commits, PRs, and artifacts directly related to the user's intent. +3. Earlier letters, memories, cross-day summaries, and old sessions that explain continuity. +4. Titles, timestamps, workspaces, paths, and system summaries used only for orientation. +5. Automatic traces, empty session shells, zero-turn sessions, startup records, and background maintenance events. -- If it changes the user's understanding of today -> put it in the body. -- If it is a stable preference, standard, boundary, or fact worth remembering across days -> put it in receiptCandidates; the body may mention it lightly at most. -- If it is a concrete next step -> put it in continuationCards; the body ending may naturally point to it, but should not expand into a checklist. +You may inspect relevant records with read-only tools: LS, Glob, Grep, and Read. Use tools by information gain: continue only when more reading could change the body focus, risk call, cross-day pattern, receipt candidate, Product App opportunity, or source attribution. -The body does not need to cover every source. A letter that explains three things fully is better than one that mentions ten things by reciting them. +Read evidence by processing level. Start from explicit snippets and day-level reports or summaries when they exist; treat them as maps and indexes. Then use session index or metadata to verify coverage and spot gaps. Read raw `turns/turn-*.json`, tool/event files, or runtime storage only as fallback when summaries are missing, contradictory, too vague, or a specific detail would change the letter. Keep the first pass lightweight; avoid reading whole session directories or all turns at the start. -## Narrative: Let The Letter Follow The Shape Of The Day +When the packet already supports judgment, write directly. Automatic traces are only for deciding whether the material is insufficient; body material should come from user-perceivable actions, questions, choices, views, and outcomes. When the window only contains automatic traces and no real risk, use ritual note mode. -For most days, the natural path of a letter is: **the thread today left behind -> a few concrete facts that anchor it -> the deeper meaning behind it -> where tomorrow can pick it up**. +Sensitive information, personal data, secrets, tokens, passwords, raw paths, and irrelevant source details may inform safety decisions only. Keep them out of all output. -But this is a default arc, not a form to fill in. Each day has its own shape; first recognize which kind of day this was, then decide how the letter should move: +## Letter Shape -- Some days follow one thread into depth; let the letter go deep with it. -- Some days have two or three parallel threads; write honestly that several threads were open today, instead of forcing them into one main thread. -- Some days involve exploration in several directions with little change; recognize the scattered actions as "building the map", and write which parts of the map became clearer. -- Some days are taken over by an unexpected issue; that issue and what it exposed are the main thread, and where the original plan stopped should also be stated. +Choose the shape and `result` from the material: -No matter the shape, three things must be present: the opening lets the user immediately recognize "yes, that is what today was", instead of listing "today you did X, Y, Z"; each middle judgment stands on a few concrete facts, choosing the ones that explain the point best rather than the most complete set; the ending places the thread gently into tomorrow, concrete enough to say "pick up from this one", but without pressure or commands. +- **Deep reading**: use `result: "letter"`. Condition: there is a clear user judgment, refusal, tradeoff, repeated calibration, or important validation result. Default body: 2-5 short paragraphs, 1 core judgment, 1-3 fact anchors. +- **Short note**: use `result: "letter"`. Condition: there is only one light signal, but it leaves an accurate small highlight. Default body: 1-3 short paragraphs, little explanation, some aftertaste. +- **Ritual note**: use `result: "insufficient_context"`. Condition: there is no returnable user meaning and no real risk. Let the body feel like a gentle arrival with space and light companionship. -Going one layer inward is what separates this letter from a log: behind the facts, what boundary did today expose? Where did friction appear? Which judgment standard appeared repeatedly? Which seemingly scattered actions actually pointed in the same direction? +Use `result: "letter"` when there is an explicit user fragment, returnable meaning, real risk, or a cross-day pattern worth naming. When the material is genuinely thin, write honestly short. -The user will receive this letter every day. Avoid the same opening and the same skeleton every day. Let today's content decide today's writing. +## Body Writing -### Go One Layer Inward, But Stand On Evidence +`bodyMarkdown` is a reflective letter from Sparo OS to the user. It distills from the collaboration an echo the user can feel, think about, or carry: an emotion being held, a judgment becoming clearer, a method being named, or a boundary becoming more tangible. -The difference between good deeper observation and bad deeper observation is specificity: +The language should be concise, spacious, and lightly poetic; when the material allows, it may be gently playful. A letter usually advances one core judgment and stops at the right moment. Depth comes from one accurate seeing, not from length. -- A good insight can name the boundary, the friction, the thread, or the preference. "What kept blocking today was really the unwritten environment assumption behind that step" is an insight; "today had some challenges, but also growth" is not. -- Every insight must grow naturally from the evidence read today. If removing a judgment does not make the letter weaker, it is probably empty; delete it. -- Write inference as inference. Facts can be stated directly. For the layer from facts inward, use a buddy's thinking-with-you voice: "I suspect", "it looks like", "I am not sure whether you would read it this way". This is honest and leaves room for the user to correct you. -- Do not invent the user's inner state. What you can see is the shape of behavior: repetition, detours, returns, escalation, putting something down. Write the shape accurately, and meaning will naturally emerge. If you are unsure what the user meant at the time, you may ask lightly, but do not conclude for them. +The body should feel interactive. A natural rhythm is: first see the user's entry point, then say what I as Sparo OS understand, wonder, add, or gently suggest, and finally return that understanding to the user. Useful phrasings include "I see...", "I would understand this as...", "I want to add one small distinction...", and "I would suggest seeing it first as...". Suggestions should feel like a companion offering a lens, not a task list. -### From Facts To Meaning (The Movement, Not The Wording) +Before writing, decide five things: -Given the same stream, a recorder and a buddy write different things: +1. **Entry**: something the user said, a judgment, a refusal, a hesitation, a validation result, or a quiet but meaningful turn. The first sentence should return the user to that experience; judgment and terminology come later. +2. **Thesis**: one internal sentence that says what the letter is about. It does not need to appear in the body, but it must govern the whole body. +3. **Companion response**: what understanding, reminder, knowledge supplement, or gentle suggestion can I offer so this is not a one-way summary? +4. **Thought resonance**: what new understanding about their method, standard, or boundary should the user be able to carry after reading? +5. **Landing point**: end on a light aftertaste, a named judgment, or a gentle space. -- The stream says: "The same validation was run multiple times, with failures concentrated in the same step." The buddy sees: today's real fight was not the code, but the unwritten assumption behind that step; this is a boundary worth fixing in place. -- The stream says: "Many module files were opened, with very few changes." The buddy sees: today was map-building, not no output. The chain was walked through once, and tomorrow's hands-on work will be faster. -- The stream says: "The same proposal changed three times." The buddy sees: the tradeoff standard that stayed unchanged across the three versions may be more worth preserving than the proposal itself. -- The stream says: "The afternoon was interrupted by an unexpected issue, and the original plan did not advance." The buddy sees: what that interruption exposed, and where the original plan's thread is still parked, so tomorrow does not have to search from scratch. +Let the body move naturally: enter through the opening, pause on one judgment worth reflecting on, unfold the tradeoff, method, boundary, or inner proportion behind it, then close with one or two sentences of aftertaste. Facts make the reflection stand; structured fields can carry the rest of the material understanding. Technical names, project names, component names, and numbers appear when the user is using them or when they would change the judgment; translate the rest into an experience the user can recognize inwardly. -What you should learn is the movement itself: from "what happened" to "what this means and how tomorrow can pick it up", with every step standing on evidence. +A good body makes the user want to keep reading because it first touches "this is about me", then gradually clarifies why. Each paragraph should begin from an experience, relationship, or question the user can feel, then add only the facts needed. Keep terminology and sources backstage unless they are themselves what the user is thinking about. -## Tone +Counterexample calibration: -Write like a capable, smart, well-bounded work buddy looking back with the user at the end of the day: +- **Detail overload** + Bad: today you moved from v1 to v2, added a 22px preview, vertical connector, detail panel, double rAF, and fixed six transition issues. + Good: what really changed today was not a version, but the way a letter is received. +- **Process recap** + Bad: first the Design Agent produced a draft, then Review ran, then blockers were fixed, and finally a few risks remained. + Good: the key turn was the moment you saw that "functionally complete" still did not mean "relationally right." +- **Over-interpretation** + Bad: there was no effective conversation today, so you were resting, integrating, or doing deep work. + Good: there is little returnable material today, so I will keep this space lightly lit rather than invent a story for the blank. +- **One-way observation** + Bad: you rejected v1, you chose the more letter-like direction, you were protecting the reading experience. + Good: when you rejected v1, I would understand it as a relational judgment: you did not only want the page to be complete; you wanted it to arrive with the right posture. -- A little closer, lighter, and warmer, while always accurate, restrained, and trustworthy. You may use "we"; you really were present. -- The main source of warmth is being seen accurately. "You set this thread down halfway; I am keeping the place for you" is warmer than any generic "you worked hard." -- Boundaries do not mean zero emotion. When the evidence truly shows that today went through a difficult knot, or that a key step genuinely landed, you may say one natural sentence like a buddy would: concrete and just enough. "This problem circled for an afternoon, and it finally broke open from the log side; that step is worth remembering." One or two such sentences in a day are enough. -- What you should avoid is anything not grown from evidence: generic praise, formulaic comfort, sentimentality, and forced intimacy. The test is simple: if the sentence could apply to anyone on any day, delete it. -- The body may use natural small headings or no headings, but do not use report-like column names. +After drafting, run six checks: does the first sentence make the user want the next one? Does the whole body orbit one inner question? Does the body include Sparo OS responding as a companion, rather than only describing "you..."? Does it leave thought resonance with the user's place in it? Does it stand inside the collaboration between the user and Sparo OS? If project names, component names, parameters, paths, colors, and risk items were hidden, would it still read like a letter to this user? When an answer feels unstable, return to the entry and thesis, then rewrite. + +## Product App / Intelligent App Signals + +Output `appOpportunity` only when the evidence is clear. By default, all three signal types should be present: + +1. **Repeated workflow**: similar tasks, judgments, organization, validation, writing, or delivery actions recur. +2. **Stable structure**: the workflow has stable inputs, stable outputs, state tracking, checklists, templated actions, or multi-step orchestration. +3. **Clear value**: it helps the user do less repetitive work, preserve a judgment standard, reduce friction, or turn a long-term preference into an interface. + +If only one or two weak signals are present, use `null`. Write the output as a gentle opportunity, not a product pitch. The `summary` should say what it helps the user stop repeating or what judgment it preserves. ## Structured Fields -- **receiptCandidates**: include only stable, cross-day-valid content worth asking the user to confirm for long-term memory (preferences, standards, boundaries, facts). This is a candidate, not a decision; the system writes memory only after the user receipts it. Prefer fewer rather than more. -- **continuationCards**: each card is one concrete thread that can be picked up directly tomorrow, and it should explain where it naturally extends from today. Do not use vague wording such as "continue optimizing". -- **appOpportunity**: output one only when today's evidence clearly shows a repeated workflow, stable need, or productization opportunity; otherwise it must be null. It should be rare, not a fixed section in every letter. +Structured fields are JSON output outside the body, not headings or sections inside the letter. -All sourceId values must come from fragment ids present in the current packet. +- `receiptCandidates`: stable cross-day preferences, standards, boundaries, or facts worth asking the user to confirm for long-term memory. Prefer fewer. +- `appOpportunity`: output an object when an app opportunity is clear; otherwise use `null`. -## Insufficient Context +Every `sourceIds` value must be one of the packet fragment ids. -If, after exploration, today still does not have enough specific, credible, and continuable content, do not force a letter. Return `result: "insufficient_context"`, and write a short, honest, warm bodyMarkdown note saying that there is not much to gather today. Do not package scattered actions as nonexistent progress. A buddy's credibility matters more than a forced letter. In this case, receiptCandidates and continuationCards are empty arrays, and appOpportunity is null. +For `result: "insufficient_context"`, `receiptCandidates` must be an empty array, and `appOpportunity` must be `null`. -## Hard Boundaries +## Output Contract -- Do not invent operations, outcomes, timelines, preferences, emotions, or evidence. -- Do not recite every source inside the body. -- Do not write to memory directly; do not decide record id, status, storage path, badge, or seal/archive state. -- Keys, tokens, passwords, and sensitive personal data seen during exploration must not enter the letter body or any structured field. -- Return only JSON: no Markdown code fence, and no text outside the JSON. +Use the packet locale. Return only JSON, with no Markdown fence and no text outside JSON. -## Output Format +`result` can only be `"letter"` or `"insufficient_context"`. Use the date from the packet. `appOpportunity` can only be an object or `null`. -`result` can only be `"letter"` or `"insufficient_context"`; `appOpportunity` can only be the object below or `null`; use the date from the packet. +Output only evidence-supported facts and grounded inferences. Leave blank any user emotion, health, identity, whereabouts, or private state the user did not express. Leave system-internal state, memory writes, storage ids, UI badges, and archive state to the system. { "result": "letter", "preview": { - "title": "Daily Letter · YYYY-MM-DD", - "oneLine": "one warm, specific sentence saying what this letter leaves for the user" + "title": "a short, inviting, non-clickbait title grown from the body entry or thesis", + "oneLine": "one warm, specific, resonant sentence saying what this letter leaves for the user" }, "bodyMarkdown": "complete Daily Letter body Markdown", "receiptCandidates": [ @@ -135,16 +151,9 @@ If, after exploration, today still does not have enough specific, credible, and "sourceIds": ["source fragment id from the packet"] } ], - "continuationCards": [ - { - "text": "one concrete thread tomorrow can pick up", - "reason": "why this thread naturally follows from today", - "sourceIds": ["source fragment id from the packet"] - } - ], "appOpportunity": { "title": "optional app or management surface name", - "summary": "why today's evidence shows a repeated workflow, stable preference, or productization need", + "summary": "why the evidence shows a repeated workflow, stable structure, and clear value", "sourceIds": ["source fragment id from the packet"] } } diff --git a/src/crates/core/src/service/config/manager.rs b/src/crates/core/src/service/config/manager.rs index d9ee4e40..f08cbd6a 100644 --- a/src/crates/core/src/service/config/manager.rs +++ b/src/crates/core/src/service/config/manager.rs @@ -664,6 +664,7 @@ pub(crate) fn migrate_0_0_0_to_1_0_0(mut config: Value) -> CoreResult { app.insert( "ai_experience".to_string(), serde_json::json!({ + "enable_daily_letter": true, "enable_session_title_generation": true, "enable_visual_mode": false, "enable_agent_companion": true, diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 88dc3197..a374639c 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -185,6 +185,9 @@ pub struct AppHostScanConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AIExperienceConfig { + /// Whether Daily Letter generation is enabled. + #[serde(default = "default_true")] + pub enable_daily_letter: bool, /// Whether to enable automatic AI-generated summaries for session titles. pub enable_session_title_generation: bool, /// Whether to enable visual mode. @@ -1434,6 +1437,7 @@ impl Default for AppHostScanConfig { impl Default for AIExperienceConfig { fn default() -> Self { Self { + enable_daily_letter: true, enable_session_title_generation: true, enable_visual_mode: false, enable_agent_companion: true, diff --git a/src/crates/core/src/service/daily_letter/mod.rs b/src/crates/core/src/service/daily_letter/mod.rs index 184fc89d..0768b018 100644 --- a/src/crates/core/src/service/daily_letter/mod.rs +++ b/src/crates/core/src/service/daily_letter/mod.rs @@ -10,11 +10,9 @@ pub use service::{ pub use types::{ DailyLetterAgentOutput, DailyLetterAgentResult, DailyLetterAppOpportunity, DailyLetterApplyReceiptsRequest, DailyLetterAttemptStatus, DailyLetterContextPacket, - DailyLetterContinuationCard, DailyLetterGenerateRequest, - DailyLetterGetRequest, DailyLetterListRequest, DailyLetterPreview, DailyLetterReceiptAction, - DailyLetterReceiptCandidate, DailyLetterReceiptStatus, DailyLetterRecord, - DailyLetterRecordStatus, DailyLetterRunSummary, DailyLetterScope, DailyLetterSealRequest, - DailyLetterSourceFragment, DailyLetterSourceFragmentType, DailyLetterSourceStats, - DailyLetterState, DailyLetterTrigger, DailyLetterUpdateContinuationRequest, - DailyLetterWorkspaceRef, + DailyLetterGenerateRequest, DailyLetterGetRequest, DailyLetterListRequest, DailyLetterPreview, + DailyLetterReceiptAction, DailyLetterReceiptCandidate, DailyLetterReceiptStatus, + DailyLetterRecord, DailyLetterRecordStatus, DailyLetterRunSummary, DailyLetterScope, + DailyLetterSealRequest, DailyLetterSourceFragment, DailyLetterSourceFragmentType, + DailyLetterSourceStats, DailyLetterState, DailyLetterTrigger, DailyLetterWorkspaceRef, }; diff --git a/src/crates/core/src/service/daily_letter/prompt.rs b/src/crates/core/src/service/daily_letter/prompt.rs index c671d76b..aac3c26f 100644 --- a/src/crates/core/src/service/daily_letter/prompt.rs +++ b/src/crates/core/src/service/daily_letter/prompt.rs @@ -18,11 +18,10 @@ pub(crate) fn build_daily_letter_user_prompt( )) })?; Ok(format!( - "Write today's Daily Letter from this context packet.\n\ -The packet names the date, locale, scope, source ids, and sourcePath entry points most likely to contain today's signal. Treat it as a map for reconstructing the day, not as a request to recite every source.\n\ -Use only the read-only tools LS, Glob, Grep, and Read. Start from the packet sourcePath entries and follow relevant runtime records as needed to understand today's threads. Do not browse the web, run commands, write files, edit files, or delete files.\n\ -Raw records may contain secrets or personal data. Use them only to understand the day; do not include secrets or sensitive personal data in the letter or structured fields.\n\ -Every sourceIds value in your JSON must be one of the source ids in the packet.\n\ + "Write Daily Letter from this context packet.\n\ +Follow the DailyLetterWriter system prompt as the authoritative contract.\n\ +Use the packet as the evidence map. Inspect read-only sources only when the added information could change the body, risk call, cross-day pattern, receipt candidate, Product App opportunity, or source attribution.\n\ +Use the packet locale, date, and coverage window. The letter covers material after the previous Daily Letter, not only the calendar date. Every sourceIds value in your JSON must be one of the packet fragment ids.\n\ Return only JSON.\n\n{}", json )) diff --git a/src/crates/core/src/service/daily_letter/service.rs b/src/crates/core/src/service/daily_letter/service.rs index cd7e9376..345557fd 100644 --- a/src/crates/core/src/service/daily_letter/service.rs +++ b/src/crates/core/src/service/daily_letter/service.rs @@ -7,13 +7,11 @@ use super::store::{ use super::types::{ DailyLetterAgentOutput, DailyLetterAgentResult, DailyLetterAppOpportunity, DailyLetterApplyReceiptsRequest, DailyLetterAttemptStatus, DailyLetterContextPacket, - DailyLetterContinuationCard, DailyLetterGenerateRequest, - DailyLetterGetRequest, DailyLetterListRequest, DailyLetterPreview, DailyLetterReceiptAction, - DailyLetterReceiptCandidate, DailyLetterReceiptStatus, DailyLetterRecord, - DailyLetterRecordStatus, DailyLetterRunSummary, DailyLetterScope, DailyLetterSealRequest, - DailyLetterSourceFragment, DailyLetterSourceFragmentType, DailyLetterSourceStats, - DailyLetterState, DailyLetterTrigger, DailyLetterUpdateContinuationRequest, - DailyLetterWorkspaceRef, + DailyLetterGenerateRequest, DailyLetterGetRequest, DailyLetterListRequest, DailyLetterPreview, + DailyLetterReceiptAction, DailyLetterReceiptCandidate, DailyLetterReceiptStatus, + DailyLetterRecord, DailyLetterRecordStatus, DailyLetterRunSummary, DailyLetterScope, + DailyLetterSealRequest, DailyLetterSourceFragment, DailyLetterSourceFragmentType, + DailyLetterSourceStats, DailyLetterState, DailyLetterTrigger, DailyLetterWorkspaceRef, }; use crate::agentic::coordination::get_global_coordinator; use crate::agentic::memory::store::{ @@ -22,7 +20,7 @@ use crate::agentic::memory::store::{ use crate::agentic_os::work::{default_work_store, WorkScope}; use crate::error::{CoreError, CoreResult}; use crate::infrastructure::get_path_manager_arc; -use crate::service::config::get_app_language_code; +use crate::service::config::{get_app_language_code, get_global_config_service, GlobalConfig}; use crate::util::extract_json_from_ai_response; use chrono::{Datelike, Duration as ChronoDuration, Local, LocalResult, NaiveDate, TimeZone}; use log::{info, warn}; @@ -40,14 +38,16 @@ use tokio::time::Duration; const AUTO_WAKE_HOUR_LOCAL: u32 = 21; const AUTO_WAKE_MINUTE_LOCAL: u32 = 30; const STARTUP_CATCH_UP_DELAY_SECS: u64 = 30; +const MAX_DAILY_REPORTS: usize = 10; const MAX_SESSION_SUMMARIES: usize = 12; const MAX_WORK_FRAGMENTS: usize = 8; const MAX_MEMORY_FRAGMENTS: usize = 4; const MAX_FRAGMENT_CHARS: usize = 2200; const MAX_RECEIPT_CANDIDATES: usize = 5; -const MAX_CONTINUATION_CARDS: usize = 5; const IDLE_OPPORTUNITY_DELAY_SECS: u64 = 75; const STALE_RUNNING_ATTEMPT_AFTER_MS: i64 = 20 * 60 * 1000; +const MAX_DAILY_LETTER_AI_ATTEMPTS: usize = 3; +const DAILY_LETTER_AI_RETRY_BASE_DELAY_MS: u64 = 800; static GLOBAL_DAILY_LETTER_SERVICE: OnceLock> = OnceLock::new(); @@ -67,6 +67,16 @@ struct MemoryJournalRecord { session_id: String, } +#[derive(Debug, Clone)] +struct DailyLetterCoverageWindow { + start_date: String, + start_at_ms: Option, + end_date: String, + end_at_ms: i64, + previous_letter_id: Option, + previous_letter_date: Option, +} + impl DailyLetterService { pub async fn new() -> CoreResult> { let mut state = load_daily_letter_state(DailyLetterScope::AgenticOs, None).await?; @@ -237,38 +247,6 @@ impl DailyLetterService { Ok(record) } - pub async fn update_continuation( - &self, - request: DailyLetterUpdateContinuationRequest, - ) -> CoreResult { - let scope = - resolve_record_request_scope(&request.record_id, request.workspace_path.as_deref()); - let mut record = get_daily_letter(DailyLetterGetRequest { - id: Some(request.record_id.clone()), - date: None, - scope: Some(scope), - workspace_path: request.workspace_path.clone(), - }) - .await? - .ok_or_else(|| CoreError::validation("Daily letter record was not found"))?; - - let Some(card) = record - .continuation_cards - .iter_mut() - .find(|item| item.id == request.continuation_id) - else { - return Err(CoreError::validation(format!( - "Continuation card was not found: {}", - request.continuation_id - ))); - }; - - card.remind_tomorrow = request.remind_tomorrow; - record.updated_at_ms = now_ms(); - save_daily_letter_record(&record).await?; - Ok(record) - } - pub async fn state_snapshot(&self) -> DailyLetterState { self.state.lock().await.clone() } @@ -349,6 +327,16 @@ impl DailyLetterService { let date = request.date.unwrap_or_else(today_local_date_key); validate_date_key(&date)?; + if !is_daily_letter_enabled().await { + return Ok(DailyLetterRunSummary { + started: false, + trigger, + date: Some(date), + record: None, + reason: Some("Daily Letter is disabled in settings".to_string()), + }); + } + if !request.force { if let Some(record) = load_daily_letter_record(&date, scope, workspace_path).await? { return Ok(DailyLetterRunSummary { @@ -467,6 +455,18 @@ impl DailyLetterService { } } +async fn is_daily_letter_enabled() -> bool { + match get_global_config_service().await { + Ok(config_service) => { + let config: CoreResult = config_service.get_config(None).await; + config + .map(|config| config.app.ai_experience.enable_daily_letter) + .unwrap_or(true) + } + Err(_) => true, + } +} + pub fn install_global_daily_letter_service(service: Arc) -> Result<(), ()> { GLOBAL_DAILY_LETTER_SERVICE.set(service).map_err(|_| ()) } @@ -495,12 +495,14 @@ async fn build_context_packet( ) -> CoreResult { let locale = get_app_language_code().await; let workspace = workspace_path.map(workspace_ref_for_path); + let coverage = resolve_coverage_window(date, scope, workspace_path).await?; let mut fragments = Vec::new(); - fragments.extend(collect_exploration_target_fragments(date, scope, workspace_path).await?); - fragments.extend(collect_session_summary_fragments(date, scope, workspace_path).await?); - fragments.extend(collect_work_fragments(date, scope, workspace_path).await?); - fragments.extend(collect_git_fragments(date, workspace_path).await?); + fragments.extend(collect_daily_report_fragments(&coverage, scope).await?); + fragments.extend(collect_exploration_target_fragments(&coverage, scope, workspace_path).await?); + fragments.extend(collect_session_summary_fragments(&coverage, scope, workspace_path).await?); + fragments.extend(collect_work_fragments(&coverage, scope, workspace_path).await?); + fragments.extend(collect_git_fragments(&coverage, workspace_path).await?); let memory_context = collect_memory_context_fragments(scope, workspace_path).await?; let user_preferences = memory_context @@ -516,6 +518,10 @@ async fn build_context_packet( .collect::>(); let source_stats = DailyLetterSourceStats { + daily_report_count: fragments + .iter() + .filter(|item| item.fragment_type == DailyLetterSourceFragmentType::DailyReport) + .count(), session_summary_count: fragments .iter() .filter(|item| item.fragment_type == DailyLetterSourceFragmentType::SessionSummary) @@ -546,6 +552,11 @@ async fn build_context_packet( Ok(DailyLetterContextPacket { date: date.to_string(), + coverage_start_date: Some(coverage.start_date), + coverage_start_at_ms: coverage.start_at_ms, + coverage_end_at_ms: Some(coverage.end_at_ms), + previous_letter_id: coverage.previous_letter_id, + previous_letter_date: coverage.previous_letter_date, locale, scope, workspace, @@ -556,6 +567,56 @@ async fn build_context_packet( }) } +async fn resolve_coverage_window( + date: &str, + scope: DailyLetterScope, + workspace_path: Option<&Path>, +) -> CoreResult { + let previous = previous_daily_letter_record(date, scope, workspace_path).await?; + Ok(DailyLetterCoverageWindow { + start_date: previous + .as_ref() + .map(|record| record.date.clone()) + .unwrap_or_else(|| date.to_string()), + start_at_ms: previous.as_ref().map(|record| record.created_at_ms), + end_date: date.to_string(), + end_at_ms: now_ms(), + previous_letter_id: previous.as_ref().map(|record| record.id.clone()), + previous_letter_date: previous.as_ref().map(|record| record.date.clone()), + }) +} + +async fn previous_daily_letter_record( + date: &str, + scope: DailyLetterScope, + workspace_path: Option<&Path>, +) -> CoreResult> { + let target_date = parse_date_key(date)?; + let records = list_daily_letters(DailyLetterListRequest { + scope: Some(scope), + workspace_path: workspace_path.map(|path| path.to_string_lossy().to_string()), + limit: None, + }) + .await?; + + let mut previous = records + .into_iter() + .filter(|record| { + parse_date_key(&record.date) + .map(|record_date| record_date < target_date) + .unwrap_or(false) + }) + .collect::>(); + + previous.sort_by(|left, right| { + right + .date + .cmp(&left.date) + .then_with(|| right.created_at_ms.cmp(&left.created_at_ms)) + }); + Ok(previous.into_iter().next()) +} + fn workspace_ref_for_path(path: &Path) -> DailyLetterWorkspaceRef { let path_manager = get_path_manager_arc(); let name = path @@ -571,8 +632,46 @@ fn workspace_ref_for_path(path: &Path) -> DailyLetterWorkspaceRef { } } +async fn collect_daily_report_fragments( + coverage: &DailyLetterCoverageWindow, + scope: DailyLetterScope, +) -> CoreResult> { + if scope != DailyLetterScope::AgenticOs { + return Ok(Vec::new()); + } + let mut fragments = Vec::new(); + for date_key in coverage_date_keys(coverage)? + .into_iter() + .take(MAX_DAILY_REPORTS) + { + let year = date_key.split('-').next().unwrap_or("unknown"); + let path = get_path_manager_arc() + .agentic_os_daily_reports_dir() + .join(year) + .join(format!("{date_key}.md")); + if !path.exists() { + continue; + } + fragments.push(DailyLetterSourceFragment { + id: format!("daily-report-{}", fragments.len() + 1), + fragment_type: DailyLetterSourceFragmentType::DailyReport, + title: format!("Global daily report {}", date_key), + summary: format!( + "High-level daily report for {} within the coverage window {}..{}. Read this before raw session files; if the report includes material from before the previous letter, use lower-level sources to keep only what matters after the last letter.", + date_key, + coverage.start_date, + coverage.end_date + ), + evidence_label: Some(path_string(&path)), + source_path: Some(path_string(path)), + confidence: 0.88, + }); + } + Ok(fragments) +} + async fn collect_exploration_target_fragments( - date: &str, + coverage: &DailyLetterCoverageWindow, scope: DailyLetterScope, workspace_path: Option<&Path>, ) -> CoreResult> { @@ -587,8 +686,9 @@ async fn collect_exploration_target_fragments( DailyLetterSourceFragmentType::Event, format!("Session event stream {}", index + 1), format!( - "Explore session turns, metadata, and model/tool event files under this runtime root for {}. Prefer daily_summaries first, then turns/*.json only when needed.", - date + "Fallback source root for the coverage window {}..{}. Prefer injected daily reports and daily_summaries first; use index/metadata to find relevant sessions since the previous letter, and read turns/*.json only sparingly when summaries are missing or a specific detail would change the letter.", + coverage.start_date, + coverage.end_date ), root, 0.72, @@ -603,8 +703,9 @@ async fn collect_exploration_target_fragments( DailyLetterSourceFragmentType::Command, format!("Command and tool summaries {}", index + 1), format!( - "Use Grep/Glob under this runtime root to find command, tool, terminal, and execution result summaries for {}. Keep only concise evidence.", - date + "Use Grep/Glob under this runtime root to find command, tool, terminal, and execution result summaries for the coverage window {}..{}. Keep only concise evidence after the previous letter.", + coverage.start_date, + coverage.end_date ), root, 0.7, @@ -632,8 +733,8 @@ async fn collect_exploration_target_fragments( DailyLetterSourceFragmentType::Explicit, "Explicitly added daily letter snippets".to_string(), format!( - "Read snippets the user explicitly placed in today's Daily Letter inbox for {}.", - date + "Read snippets the user explicitly placed in the Daily Letter inbox for the coverage window ending {}.", + coverage.end_date ), explicit_root, 0.9, @@ -700,24 +801,28 @@ fn push_path_fragment( } async fn collect_session_summary_fragments( - date: &str, + coverage: &DailyLetterCoverageWindow, scope: DailyLetterScope, workspace_path: Option<&Path>, ) -> CoreResult> { - let source_paths = collect_session_daily_summary_sources(date, scope, workspace_path).await?; + let source_paths = + collect_session_daily_summary_sources(coverage, scope, workspace_path).await?; let mut fragments = Vec::new(); for (index, path) in source_paths .into_iter() .take(MAX_SESSION_SUMMARIES) .enumerate() { + let source_date = source_date_from_path(&path); fragments.push(DailyLetterSourceFragment { id: format!("session-summary-{}", index + 1), fragment_type: DailyLetterSourceFragmentType::SessionSummary, - title: format!("Session summary {}", index + 1), + title: format!("Session summary {} {}", source_date, index + 1), summary: format!( - "Daily summary file for {}. Read this sourcePath before citing it in the letter.", - date + "Daily summary file for {} within the coverage window {}..{}. Read this sourcePath before citing it in the letter, and keep the letter focused on material after the previous letter.", + source_date, + coverage.start_date, + coverage.end_date ), evidence_label: Some(path_string(&path)), source_path: Some(path_string(path)), @@ -728,7 +833,7 @@ async fn collect_session_summary_fragments( } async fn collect_work_fragments( - date: &str, + coverage: &DailyLetterCoverageWindow, scope: DailyLetterScope, workspace_path: Option<&Path>, ) -> CoreResult> { @@ -740,7 +845,6 @@ async fn collect_work_fragments( } }; let records = store.list().await?; - let target_date = parse_date_key(date)?; let mut fragments = Vec::new(); for work in records { if fragments.len() >= MAX_WORK_FRAGMENTS { @@ -749,7 +853,7 @@ async fn collect_work_fragments( if !work_matches_scope(&work.scope, scope, workspace_path) { continue; } - if local_date_from_ms(work.updated_at) != Some(target_date) { + if !timestamp_in_coverage(coverage, work.updated_at)? { continue; } let mut summary = format!( @@ -803,7 +907,7 @@ fn work_matches_scope( } async fn collect_git_fragments( - date: &str, + coverage: &DailyLetterCoverageWindow, workspace_path: Option<&Path>, ) -> CoreResult> { let Some(workspace_path) = workspace_path else { @@ -814,14 +918,20 @@ async fn collect_git_fragments( } let mut fragments = Vec::new(); + let since = coverage + .start_at_ms + .and_then(local_datetime_string_from_ms) + .unwrap_or_else(|| coverage.start_date.clone()); + let until = local_datetime_string_from_ms(coverage.end_at_ms) + .unwrap_or_else(|| coverage.end_date.clone()); if let Some(log) = run_git( workspace_path, &[ "log", "--since", - date, + &since, "--until", - &next_date_key(date)?, + &until, "--oneline", "--decorate", "-8", @@ -902,7 +1012,7 @@ async fn collect_memory_context_fragments( async fn generate_letter_with_ai( packet: &DailyLetterContextPacket, ) -> CoreResult { - let user_prompt = build_daily_letter_user_prompt(packet)?; + let base_user_prompt = build_daily_letter_user_prompt(packet)?; let workspace_path = packet .workspace .as_ref() @@ -915,16 +1025,77 @@ async fn generate_letter_with_ai( }); let coordinator = get_global_coordinator() .ok_or_else(|| CoreError::service("Conversation coordinator is not initialized"))?; - let response_text = coordinator - .execute_hidden_daily_letter_writer( - &packet_record_key(packet), - format!("Daily Letter {}", packet.date), - workspace_path, - user_prompt, - None, - ) - .await?; - let json = extract_json_from_ai_response(&response_text).ok_or_else(|| { + let record_key = packet_record_key(packet); + let mut last_error: Option = None; + + for attempt in 1..=MAX_DAILY_LETTER_AI_ATTEMPTS { + let previous_error = last_error.as_ref().map(ToString::to_string); + let user_prompt = build_daily_letter_attempt_prompt( + &base_user_prompt, + attempt, + MAX_DAILY_LETTER_AI_ATTEMPTS, + previous_error.as_deref(), + ); + let request_id = format!("{}-attempt-{}", record_key, attempt); + let session_name = if MAX_DAILY_LETTER_AI_ATTEMPTS == 1 { + format!("Daily Letter {}", packet.date) + } else { + format!( + "Daily Letter {} attempt {}/{}", + packet.date, attempt, MAX_DAILY_LETTER_AI_ATTEMPTS + ) + }; + + let attempt_result = match coordinator + .execute_hidden_daily_letter_writer( + &request_id, + session_name, + workspace_path.clone(), + user_prompt, + None, + ) + .await + { + Ok(response_text) => parse_daily_letter_agent_output(&response_text), + Err(error) => Err(error), + }; + + match attempt_result { + Ok(output) => { + if attempt > 1 { + info!( + "Daily letter AI generation succeeded after retry: attempt={}, max_attempts={}, date={}, scope={:?}", + attempt, MAX_DAILY_LETTER_AI_ATTEMPTS, packet.date, packet.scope + ); + } + return Ok(output); + } + Err(error) if attempt < MAX_DAILY_LETTER_AI_ATTEMPTS => { + let delay_ms = daily_letter_ai_retry_delay_ms(attempt); + warn!( + "Daily letter AI generation attempt failed; retrying: attempt={}, max_attempts={}, delay_ms={}, date={}, scope={:?}, error={}", + attempt, MAX_DAILY_LETTER_AI_ATTEMPTS, delay_ms, packet.date, packet.scope, error + ); + last_error = Some(error); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + Err(error) => { + warn!( + "Daily letter AI generation exhausted retries: attempts={}, date={}, scope={:?}, error={}", + MAX_DAILY_LETTER_AI_ATTEMPTS, packet.date, packet.scope, error + ); + return Err(error); + } + } + } + + Err(CoreError::service( + "Daily letter AI generation ended without an attempt result", + )) +} + +fn parse_daily_letter_agent_output(response_text: &str) -> CoreResult { + let json = extract_json_from_ai_response(response_text).ok_or_else(|| { CoreError::parse("Daily letter writer response did not contain valid JSON") })?; serde_json::from_str::(&json).map_err(|error| { @@ -935,6 +1106,30 @@ async fn generate_letter_with_ai( }) } +fn build_daily_letter_attempt_prompt( + base_user_prompt: &str, + attempt: usize, + max_attempts: usize, + previous_error: Option<&str>, +) -> String { + if attempt <= 1 { + return base_user_prompt.to_string(); + } + + let previous_error = previous_error.unwrap_or("the previous attempt failed"); + format!( + "{}\n\nRetry instruction:\n\ +The previous attempt failed: {}.\n\ +This is attempt {}/{}.\n\ +Return exactly one JSON object that matches the Output Contract. Do not include analysis, preface text, markdown fences, or any text outside the JSON object.", + base_user_prompt, previous_error, attempt, max_attempts + ) +} + +fn daily_letter_ai_retry_delay_ms(failed_attempt: usize) -> u64 { + DAILY_LETTER_AI_RETRY_BASE_DELAY_MS * failed_attempt as u64 +} + fn packet_record_key(packet: &DailyLetterContextPacket) -> String { daily_letter_record_id( &packet.date, @@ -974,20 +1169,6 @@ fn build_record_from_agent_output( .filter(|item| !item.text.is_empty() && !item.source_ids.is_empty()) .take(MAX_RECEIPT_CANDIDATES) .collect::>(); - let continuation_cards = output - .continuation_cards - .into_iter() - .enumerate() - .map(|(index, item)| DailyLetterContinuationCard { - id: format!("next-{}", index + 1), - text: item.text.trim().to_string(), - reason: item.reason.map(|value| value.trim().to_string()), - source_ids: filter_source_ids(item.source_ids, &valid_source_ids), - remind_tomorrow: false, - }) - .filter(|item| !item.text.is_empty() && !item.source_ids.is_empty()) - .take(MAX_CONTINUATION_CARDS) - .collect::>(); let app_opportunity = output .app_opportunity .map(|item| DailyLetterAppOpportunity { @@ -1022,7 +1203,6 @@ fn build_record_from_agent_output( ), one_line: non_empty_or(output.preview.one_line.trim(), "今天的线索已经为你收好。"), receipt_count: receipt_candidates.len(), - continuation_count: continuation_cards.len(), app_idea_count: usize::from(app_opportunity.is_some()), }, body_markdown: non_empty_or( @@ -1030,7 +1210,6 @@ fn build_record_from_agent_output( "今天的上下文比较轻,我先把能确认的部分留在这里。", ), receipt_candidates, - continuation_cards, app_opportunity, created_at_ms: now, updated_at_ms: now, @@ -1081,13 +1260,6 @@ fn validate_daily_letter_record( )); } } - for card in &record.continuation_cards { - if card.source_ids.is_empty() { - return Err(CoreError::validation( - "Daily letter continuation cards must include source ids", - )); - } - } if let Some(app) = record.app_opportunity.as_ref() { if app.source_ids.is_empty() { return Err(CoreError::validation( @@ -1123,7 +1295,6 @@ fn build_insufficient_context_record(packet: &DailyLetterContextPacket) -> Daily "今天能看见的上下文还不多,我先把这封空白处留给你。".to_string() }, receipt_count: 0, - continuation_count: 0, app_idea_count: 0, }, body_markdown: if is_en { @@ -1132,7 +1303,6 @@ fn build_insufficient_context_record(packet: &DailyLetterContextPacket) -> Daily "今天我能看见的上下文还不够多,所以不硬写成一份总结。\n\n等有新的会话摘要、工作状态、命令结果、Git 活动,或你主动加入今日来信的片段,我会把它们整理成一封更像信的回望。".to_string() }, receipt_candidates: Vec::new(), - continuation_cards: Vec::new(), app_opportunity: None, created_at_ms: now, updated_at_ms: now, @@ -1197,11 +1367,14 @@ async fn append_receipt_memory( } async fn collect_session_daily_summary_sources( - date: &str, + coverage: &DailyLetterCoverageWindow, scope: DailyLetterScope, workspace_path: Option<&Path>, ) -> CoreResult> { - let target_file_name = format!("{date}.md"); + let target_file_names = coverage_date_keys(coverage)? + .into_iter() + .map(|date_key| format!("{date_key}.md")) + .collect::>(); let path_manager = get_path_manager_arc(); let roots = match scope { DailyLetterScope::AgenticOs => { @@ -1225,16 +1398,21 @@ async fn collect_session_daily_summary_sources( let mut result = Vec::new(); for root in roots { - collect_daily_summary_files_under(&root, &target_file_name, &mut result).await?; + collect_daily_summary_files_under(&root, &target_file_names, &mut result).await?; } - result.sort(); + result.sort_by(|left, right| { + right + .file_name() + .cmp(&left.file_name()) + .then_with(|| left.cmp(right)) + }); result.dedup(); Ok(result) } async fn collect_daily_summary_files_under( sessions_dir: &Path, - target_file_name: &str, + target_file_names: &HashSet, result: &mut Vec, ) -> CoreResult<()> { if !sessions_dir.exists() { @@ -1246,14 +1424,69 @@ async fn collect_daily_summary_files_under( if !daily_summaries_dir.exists() { continue; } - let path = daily_summaries_dir.join(target_file_name); - if path.exists() { - result.push(path); + for target_file_name in target_file_names { + let path = daily_summaries_dir.join(target_file_name); + if path.exists() { + result.push(path); + } } } Ok(()) } +fn coverage_date_keys(coverage: &DailyLetterCoverageWindow) -> CoreResult> { + let mut current = parse_date_key(&coverage.start_date)?; + let end = parse_date_key(&coverage.end_date)?; + if current > end { + current = end; + } + + let mut dates = Vec::new(); + while current <= end { + dates.push(current.format("%Y-%m-%d").to_string()); + current += ChronoDuration::days(1); + } + Ok(dates) +} + +fn timestamp_in_coverage( + coverage: &DailyLetterCoverageWindow, + timestamp_ms: i64, +) -> CoreResult { + if let Some(start_at_ms) = coverage.start_at_ms { + if timestamp_ms <= start_at_ms { + return Ok(false); + } + } + if timestamp_ms > coverage.end_at_ms { + return Ok(false); + } + + let Some(date) = local_date_from_ms(timestamp_ms) else { + return Ok(false); + }; + Ok( + date >= parse_date_key(&coverage.start_date)? + && date <= parse_date_key(&coverage.end_date)?, + ) +} + +fn source_date_from_path(path: &Path) -> String { + path.file_stem() + .and_then(|value| value.to_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("unknown-date") + .to_string() +} + +fn local_datetime_string_from_ms(timestamp_ms: i64) -> Option { + match Local.timestamp_millis_opt(timestamp_ms) { + LocalResult::Single(value) => Some(value.to_rfc3339()), + LocalResult::Ambiguous(value, _) => Some(value.to_rfc3339()), + LocalResult::None => None, + } +} + async fn run_git(workspace_path: &Path, args: &[&str]) -> Option { let mut command = Command::new("git"); command.arg("-C").arg(workspace_path); @@ -1321,12 +1554,6 @@ fn parse_date_key(date: &str) -> CoreResult { }) } -fn next_date_key(date: &str) -> CoreResult { - Ok((parse_date_key(date)? + ChronoDuration::days(1)) - .format("%Y-%m-%d") - .to_string()) -} - fn local_date_from_ms(timestamp_ms: i64) -> Option { Local .timestamp_millis_opt(timestamp_ms) diff --git a/src/crates/core/src/service/daily_letter/store.rs b/src/crates/core/src/service/daily_letter/store.rs index 6df7c82a..b67096f6 100644 --- a/src/crates/core/src/service/daily_letter/store.rs +++ b/src/crates/core/src/service/daily_letter/store.rs @@ -293,14 +293,6 @@ fn render_daily_letter_markdown(record: &DailyLetterRecord) -> String { out.push('\n'); } - if !record.continuation_cards.is_empty() { - out.push_str("## 明日可拾\n\n"); - for item in &record.continuation_cards { - out.push_str(&format!("- {}\n", item.text)); - } - out.push('\n'); - } - if let Some(app) = record.app_opportunity.as_ref() { out.push_str("## P.S.\n\n"); out.push_str(&format!( diff --git a/src/crates/core/src/service/daily_letter/types.rs b/src/crates/core/src/service/daily_letter/types.rs index 67663b8f..8dfdc60a 100644 --- a/src/crates/core/src/service/daily_letter/types.rs +++ b/src/crates/core/src/service/daily_letter/types.rs @@ -34,6 +34,7 @@ pub enum DailyLetterReceiptStatus { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DailyLetterSourceFragmentType { + DailyReport, SessionSummary, Event, Work, @@ -70,6 +71,8 @@ pub struct DailyLetterSourceFragment { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DailyLetterSourceStats { + #[serde(default)] + pub daily_report_count: usize, pub session_summary_count: usize, #[serde(default)] pub event_count: usize, @@ -87,6 +90,16 @@ pub struct DailyLetterSourceStats { #[serde(rename_all = "camelCase")] pub struct DailyLetterContextPacket { pub date: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coverage_start_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coverage_start_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coverage_end_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_letter_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_letter_date: Option, pub locale: String, pub scope: DailyLetterScope, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -123,16 +136,6 @@ pub struct DailyLetterAgentReceiptCandidate { pub source_ids: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DailyLetterAgentContinuationCard { - pub text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, - #[serde(default)] - pub source_ids: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DailyLetterAgentAppOpportunity { @@ -150,8 +153,6 @@ pub struct DailyLetterAgentOutput { pub body_markdown: String, #[serde(default)] pub receipt_candidates: Vec, - #[serde(default)] - pub continuation_cards: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub app_opportunity: Option, } @@ -162,7 +163,6 @@ pub struct DailyLetterPreview { pub title: String, pub one_line: String, pub receipt_count: usize, - pub continuation_count: usize, pub app_idea_count: usize, } @@ -184,19 +184,6 @@ pub struct DailyLetterReceiptCandidate { pub decided_at_ms: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DailyLetterContinuationCard { - pub id: String, - pub text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, - #[serde(default)] - pub source_ids: Vec, - #[serde(default)] - pub remind_tomorrow: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DailyLetterAppOpportunity { @@ -220,8 +207,6 @@ pub struct DailyLetterRecord { pub body_markdown: String, #[serde(default)] pub receipt_candidates: Vec, - #[serde(default)] - pub continuation_cards: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub app_opportunity: Option, pub created_at_ms: i64, @@ -300,16 +285,6 @@ pub struct DailyLetterSealRequest { pub workspace_path: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DailyLetterUpdateContinuationRequest { - pub record_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_path: Option, - pub continuation_id: String, - pub remind_tomorrow: bool, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DailyLetterAttemptStatus { diff --git a/src/crates/core/src/service/global_daily_report/service.rs b/src/crates/core/src/service/global_daily_report/service.rs index f284a841..071b043f 100644 --- a/src/crates/core/src/service/global_daily_report/service.rs +++ b/src/crates/core/src/service/global_daily_report/service.rs @@ -23,6 +23,7 @@ use uuid::Uuid; const DAILY_WAKE_HOUR_LOCAL: u32 = 0; const DAILY_WAKE_MINUTE_LOCAL: u32 = 5; const STARTUP_CATCH_UP_DELAY_SECS: u64 = 15; +const STALE_RUNNING_ATTEMPT_AFTER_MS: i64 = 2 * 60 * 60 * 1000; static GLOBAL_GLOBAL_DAILY_REPORT_SERVICE: OnceLock> = OnceLock::new(); @@ -36,7 +37,21 @@ pub struct GlobalDailyReportService { impl GlobalDailyReportService { pub async fn new(coordinator: Arc) -> CoreResult> { - let state = load_global_daily_report_state().await?; + let mut state = load_global_daily_report_state().await?; + if matches!( + state.last_attempt_status, + Some(GlobalDailyReportAttemptStatus::Running) + ) { + warn!( + "Recovering interrupted global daily report run on startup: active_report_date={:?}, active_turn_id={:?}", + state.active_report_date, state.active_turn_id + ); + mark_global_daily_report_run_interrupted( + &mut state, + "Previous global daily report run was interrupted before completion", + ); + save_global_daily_report_state(&state).await?; + } Ok(Arc::new(Self { coordinator, state: Mutex::new(state), @@ -137,9 +152,28 @@ impl GlobalDailyReportService { } async fn run_catch_up(&self) -> CoreResult<()> { - if self.state.lock().await.active_turn_id.is_some() { - info!("Global daily report catch-up skipped because a report turn is already active"); - return Ok(()); + { + let mut state = self.state.lock().await; + if matches!( + state.last_attempt_status, + Some(GlobalDailyReportAttemptStatus::Running) + ) || state.active_turn_id.is_some() + { + if is_stale_global_daily_report_run(&state) { + warn!( + "Clearing stale global daily report run before catch-up: active_report_date={:?}, active_turn_id={:?}", + state.active_report_date, state.active_turn_id + ); + mark_global_daily_report_run_interrupted( + &mut state, + "Previous global daily report run expired before completion", + ); + save_global_daily_report_state(&state).await?; + } else { + info!("Global daily report catch-up skipped because a report turn is already active"); + return Ok(()); + } + } } while let Some(target_date) = self.next_due_report_date().await? { @@ -196,6 +230,7 @@ impl GlobalDailyReportService { let mut state = self.state.lock().await; state.last_attempt_started_at_ms = Some(now_ms()); + state.last_attempt_finished_at_ms = None; state.last_attempt_status = Some(GlobalDailyReportAttemptStatus::Running); state.last_error = None; state.active_turn_id = Some(turn_id); @@ -321,6 +356,24 @@ fn global_daily_report_output_path(date_key: &str) -> PathBuf { .join(format!("{date_key}.md")) } +fn is_stale_global_daily_report_run(state: &GlobalDailyReportState) -> bool { + state + .last_attempt_started_at_ms + .map(|started_at_ms| { + now_ms().saturating_sub(started_at_ms) > STALE_RUNNING_ATTEMPT_AFTER_MS + }) + .unwrap_or(true) +} + +fn mark_global_daily_report_run_interrupted(state: &mut GlobalDailyReportState, reason: &str) { + state.last_attempt_finished_at_ms = Some(now_ms()); + state.last_attempt_status = Some(GlobalDailyReportAttemptStatus::Cancelled); + state.last_error = Some(reason.to_string()); + state.last_attempted_date = state.active_report_date.clone(); + state.active_turn_id = None; + state.active_report_date = None; +} + async fn earliest_available_report_date() -> CoreResult> { let mut dates = Vec::new(); for path in collect_all_daily_summary_files().await? { diff --git a/src/crates/core/src/service/global_milestone/service.rs b/src/crates/core/src/service/global_milestone/service.rs index 1a61c812..34b441e4 100644 --- a/src/crates/core/src/service/global_milestone/service.rs +++ b/src/crates/core/src/service/global_milestone/service.rs @@ -26,6 +26,7 @@ const AUTO_RUN_INTERVAL_DAYS: i64 = 7; const AUTO_WAKE_HOUR_LOCAL: u32 = 0; const AUTO_WAKE_MINUTE_LOCAL: u32 = 10; const STARTUP_CATCH_UP_DELAY_SECS: u64 = 20; +const STALE_RUNNING_ATTEMPT_AFTER_MS: i64 = 2 * 60 * 60 * 1000; static GLOBAL_GLOBAL_MILESTONE_SERVICE: OnceLock> = OnceLock::new(); @@ -55,7 +56,21 @@ pub struct GlobalMilestoneService { impl GlobalMilestoneService { pub async fn new(coordinator: Arc) -> CoreResult> { - let state = load_global_milestone_state().await?; + let mut state = load_global_milestone_state().await?; + if matches!( + state.last_attempt_status, + Some(GlobalMilestoneAttemptStatus::Running) + ) { + warn!( + "Recovering interrupted global milestone run on startup: active_source_start_date={:?}, active_source_end_date={:?}, active_turn_id={:?}", + state.active_source_start_date, state.active_source_end_date, state.active_turn_id + ); + mark_global_milestone_run_interrupted( + &mut state, + "Previous global milestone run was interrupted before completion", + ); + save_global_milestone_state(&state).await?; + } Ok(Arc::new(Self { coordinator, state: Mutex::new(state), @@ -165,12 +180,28 @@ impl GlobalMilestoneService { async fn run_auto_if_due(&self) -> CoreResult<()> { let should_run = { - let state = self.state.lock().await; - if state.active_turn_id.is_some() { - info!( - "Global milestone auto run skipped because a milestone turn is already active" - ); - return Ok(()); + let mut state = self.state.lock().await; + if matches!( + state.last_attempt_status, + Some(GlobalMilestoneAttemptStatus::Running) + ) || state.active_turn_id.is_some() + { + if is_stale_global_milestone_run(&state) { + warn!( + "Clearing stale global milestone run before auto generation: active_source_start_date={:?}, active_source_end_date={:?}, active_turn_id={:?}", + state.active_source_start_date, state.active_source_end_date, state.active_turn_id + ); + mark_global_milestone_run_interrupted( + &mut state, + "Previous global milestone run expired before completion", + ); + save_global_milestone_state(&state).await?; + } else { + info!( + "Global milestone auto run skipped because a milestone turn is already active" + ); + return Ok(()); + } } state @@ -191,15 +222,34 @@ impl GlobalMilestoneService { trigger: GlobalMilestoneTrigger, ignore_schedule: bool, ) -> CoreResult { - if self.state.lock().await.active_turn_id.is_some() { - return Ok(GlobalMilestoneRunSummary { - started: false, - trigger: trigger_label(&trigger).to_string(), - source_start_date: None, - source_end_date: None, - turn_id: None, - reason: Some("A milestone run is already active".to_string()), - }); + { + let mut state = self.state.lock().await; + if matches!( + state.last_attempt_status, + Some(GlobalMilestoneAttemptStatus::Running) + ) || state.active_turn_id.is_some() + { + if is_stale_global_milestone_run(&state) { + warn!( + "Clearing stale global milestone run before manual generation: active_source_start_date={:?}, active_source_end_date={:?}, active_turn_id={:?}", + state.active_source_start_date, state.active_source_end_date, state.active_turn_id + ); + mark_global_milestone_run_interrupted( + &mut state, + "Previous global milestone run expired before completion", + ); + save_global_milestone_state(&state).await?; + } else { + return Ok(GlobalMilestoneRunSummary { + started: false, + trigger: trigger_label(&trigger).to_string(), + source_start_date: None, + source_end_date: None, + turn_id: None, + reason: Some("A milestone run is already active".to_string()), + }); + } + } } if matches!(trigger, GlobalMilestoneTrigger::Auto) && !ignore_schedule { @@ -433,6 +483,24 @@ fn next_date_key(date_key: &str) -> String { .unwrap_or_else(|_| date_key.to_string()) } +fn is_stale_global_milestone_run(state: &GlobalMilestoneState) -> bool { + state + .last_attempt_started_at_ms + .map(|started_at_ms| { + now_ms().saturating_sub(started_at_ms) > STALE_RUNNING_ATTEMPT_AFTER_MS + }) + .unwrap_or(true) +} + +fn mark_global_milestone_run_interrupted(state: &mut GlobalMilestoneState, reason: &str) { + state.last_attempt_finished_at_ms = Some(now_ms()); + state.last_attempt_status = Some(GlobalMilestoneAttemptStatus::Cancelled); + state.last_error = Some(reason.to_string()); + state.active_turn_id = None; + state.active_source_start_date = None; + state.active_source_end_date = None; +} + async fn earliest_available_daily_report_date() -> CoreResult> { let mut dates = collect_all_global_daily_report_dates().await?; dates.sort(); @@ -541,8 +609,9 @@ async fn collect_daily_report_files_under(root: &Path) -> CoreResult Option { path.file_stem() .and_then(|value| value.to_str()) - .map(str::to_string) - .filter(|value| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()) + .and_then(|value| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").ok()) + .filter(|date| *date <= Local::now().date_naive()) + .map(|date| date.format("%Y-%m-%d").to_string()) } #[cfg(test)] @@ -563,4 +632,10 @@ mod tests { Some("2026-05-17".to_string()) ); } + + #[test] + fn ignores_future_daily_report_path() { + let path = Path::new("C:/tmp/daily_reports/9999/9999-01-01.md"); + assert_eq!(daily_report_date_from_path(path), None); + } } diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index f1c5137a..5ab54126 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -45,7 +45,7 @@ pub use daily_letter::{ DailyLetterApplyReceiptsRequest, DailyLetterAttemptStatus, DailyLetterGenerateRequest, DailyLetterGetRequest, DailyLetterListRequest, DailyLetterRecord, DailyLetterRunSummary, DailyLetterScope, DailyLetterSealRequest, DailyLetterService, DailyLetterState, - DailyLetterTrigger, DailyLetterUpdateContinuationRequest, + DailyLetterTrigger, }; pub use file_watch::{ get_global_file_watch_service, get_watched_paths, initialize_file_watch_service, diff --git a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss index e5dc38fd..2d3e6b9f 100644 --- a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss +++ b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss @@ -183,10 +183,27 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' } .dl-rail__today-writing { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; +} + +.dl-rail__today-writing-label { color: var(--ds-color-accent); + font-size: inherit; + font-weight: $font-weight-medium; animation: dl-soft-pulse 2.4s ease-in-out infinite; } +.dl-rail__today-elapsed { + margin-left: auto; + color: var(--ds-color-text-tertiary); + font-size: inherit; + font-weight: $font-weight-normal; + font-variant-numeric: tabular-nums; +} + .dl-rail__section { margin-top: $size-gap-4; } @@ -634,111 +651,6 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' font-size: $font-size-sm; } -// Writing desk: shown while the letter is being written. -.dl-writing { - margin: $size-gap-4 $size-gap-6 0; - padding: $size-gap-5; - border: 1px solid color-mix(in srgb, var(--ds-color-accent) 24%, var(--ds-color-border-subtle)); - border-radius: $size-radius-base; - background: - linear-gradient( - 140deg, - color-mix(in srgb, var(--ds-color-accent) 6%, var(--ds-color-bg-elevated)) 0%, - var(--ds-color-bg-elevated) 55% - ); -} - -.dl-writing__head { - display: flex; - align-items: flex-start; - gap: $size-gap-3; - - h3 { - margin: 0 0 $size-gap-1; - font-size: $font-size-base; - font-weight: $font-weight-semibold; - } - - p { - margin: 0; - max-width: 520px; - color: var(--ds-color-text-secondary); - font-size: $font-size-sm; - line-height: $line-height-relaxed; - } -} - -.dl-writing__nib { - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - flex: 0 0 auto; - border-radius: 50%; - color: var(--ds-color-accent); - background: color-mix(in srgb, var(--ds-color-accent) 12%, transparent); - animation: dl-nib-sway 2.8s ease-in-out infinite; -} - -.dl-writing__lines { - display: grid; - gap: $size-gap-3; - max-width: 560px; - margin: $size-gap-5 0 0 42px; -} - -.dl-writing__line { - position: relative; - height: 8px; - overflow: hidden; - border-radius: 999px; - background: color-mix(in srgb, var(--ds-color-text-tertiary) 10%, transparent); - - &::after { - content: ''; - position: absolute; - inset: 0; - transform-origin: left center; - border-radius: inherit; - background: color-mix(in srgb, var(--ds-color-accent) 34%, var(--ds-color-text-tertiary)); - animation: dl-ink 3.6s $easing-ease-out infinite; - } - - &:nth-child(2)::after { - animation-delay: 0.9s; - } - - &:nth-child(3)::after { - animation-delay: 1.8s; - } - - &--short { - width: 62%; - - &::after { - animation-delay: 2.7s; - } - } -} - -.dl-writing__foot { - display: flex; - align-items: center; - justify-content: space-between; - gap: $size-gap-3; - margin: $size-gap-4 0 0 42px; - - small { - color: var(--ds-color-text-tertiary); - font-size: $font-size-xs; - } -} - -.dl-writing__elapsed { - font-variant-numeric: tabular-nums; -} - .dl-board__content { min-height: 0; overflow: auto; @@ -1497,46 +1409,6 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' } } -@keyframes dl-nib-sway { - 0%, - 100% { - transform: rotate(0deg) translateY(0); - } - - 25% { - transform: rotate(-8deg) translateY(-1px); - } - - 65% { - transform: rotate(5deg) translateY(1px); - } -} - -@keyframes dl-ink { - 0% { - transform: scaleX(0); - opacity: 0; - } - - 12% { - opacity: 1; - } - - 55% { - transform: scaleX(1); - opacity: 1; - } - - 78% { - opacity: 0.35; - } - - 100% { - transform: scaleX(1); - opacity: 0.35; - } -} - @keyframes dl-paper-unfold { from { opacity: 0; @@ -1566,8 +1438,6 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' .dl-rail__date-chip, .dl-rail__today-writing, .dl-chip__pulse, - .dl-writing__nib, - .dl-writing__line::after, .dl-paper-dialog, .dl-paper__masthead, .dl-paper__content, @@ -1581,10 +1451,6 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' transition: none; } - .dl-writing__line::after { - transform: scaleX(1); - opacity: 0.4; - } } @media (max-width: 1120px) { @@ -1635,15 +1501,6 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' } } - .dl-writing { - margin: $size-gap-3 $size-gap-4 0; - } - - .dl-writing__lines, - .dl-writing__foot { - margin-left: 0; - } - .dl-board__content--center { display: block; } diff --git a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx index 0a625ca5..a65718f5 100644 --- a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx +++ b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Archive, - Bell, CalendarDays, CalendarRange, Check, @@ -25,7 +24,6 @@ import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { createLogger } from '@/shared/utils/logger'; import { dailyLetterApi } from './dailyLetterApi'; import type { - DailyLetterContinuationCard, DailyLetterReceiptCandidate, DailyLetterRecord, DailyLetterScope, @@ -66,6 +64,7 @@ function pendingReceiptCount(letter: DailyLetterRecord): number { function generationReasonMessage(reason: string | null | undefined, t: (key: string) => string): string | null { if (!reason) return null; if (reason.includes('already active')) return null; + if (reason.includes('disabled in settings')) return t('messages.generateDisabled'); if (reason.includes('No authorized daily context')) return t('messages.generateNoSources'); if (reason.includes('already exists')) return null; return t('messages.generateNoResult'); @@ -94,6 +93,7 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => const [loading, setLoading] = useState(true); const [writing, setWriting] = useState(false); const [writingStartedAtMs, setWritingStartedAtMs] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); const [error, setError] = useState(null); const [paperOpen, setPaperOpen] = useState(false); const [dateFilterOpen, setDateFilterOpen] = useState(false); @@ -143,9 +143,11 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => const state = await dailyLetterApi.state(); if (cancelled) return; if (state.lastAttemptStatus === 'running') { - watchSinceMsRef.current = state.lastAttemptStartedAtMs ?? Date.now(); + const startedAtMs = state.lastAttemptStartedAtMs ?? Date.now(); + watchSinceMsRef.current = startedAtMs; setWriting(true); - setWritingStartedAtMs(state.lastAttemptStartedAtMs ?? Date.now()); + setWritingStartedAtMs(startedAtMs); + setNowMs(Date.now()); } } catch (stateError) { log.warn('Failed to read daily letter state', { error: stateError }); @@ -156,6 +158,13 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => }; }, []); + useEffect(() => { + if (!writing) return undefined; + setNowMs(Date.now()); + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, [writing]); + const updateLetter = useCallback((updated: DailyLetterRecord) => { setLetters((current) => { const rest = current.filter((letter) => letter.id !== updated.id); @@ -274,16 +283,19 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => const generateToday = useCallback(async () => { const targetScope: DailyLetterScope = workspacePath ? 'workspace' : 'agentic_os'; const date = todayKey(); + const startedAtMs = Date.now(); writingTargetRef.current = { date, scope: targetScope, trigger: 'manual' }; - watchSinceMsRef.current = Date.now(); + watchSinceMsRef.current = startedAtMs; setWriting(true); - setWritingStartedAtMs(Date.now()); + setWritingStartedAtMs(startedAtMs); + setNowMs(startedAtMs); setError(null); try { const summary = await dailyLetterApi.generate({ date, scope: targetScope, workspacePath: targetScope === 'workspace' ? workspacePath : null, + force: true, }); if (summary.record) { setWriting(false); @@ -368,25 +380,6 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => } }, [selectedLetter, t, updateLetter]); - const updateContinuation = useCallback(async ( - card: DailyLetterContinuationCard, - remindTomorrow: boolean - ) => { - if (!selectedLetter) return; - try { - const updated = await dailyLetterApi.updateContinuation({ - recordId: selectedLetter.id, - workspacePath: selectedLetter.workspace?.path ?? null, - continuationId: card.id, - remindTomorrow, - }); - updateLetter(updated); - } catch (continuationError) { - log.error('Failed to update daily letter continuation', { error: continuationError }); - setError(t('messages.continuationFailed')); - } - }, [selectedLetter, t, updateLetter]); - const weekdayLabel = useCallback((date: string) => { try { return formatDate(parseDateKey(date), { weekday: 'short' }); @@ -395,6 +388,17 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => } }, [formatDate]); + const writingElapsedLabel = useMemo(() => { + if (!writing || !writingStartedAtMs) return null; + const elapsedSeconds = Math.max(0, Math.floor((nowMs - writingStartedAtMs) / 1000)); + return elapsedSeconds < 60 + ? t('writing.elapsed', { seconds: elapsedSeconds }) + : t('writing.elapsedMinutes', { + minutes: Math.floor(elapsedSeconds / 60), + seconds: elapsedSeconds % 60, + }); + }, [nowMs, t, writing, writingStartedAtMs]); + return (
@@ -419,7 +423,12 @@ const DailyLetterScene: React.FC = ({ workspacePath }) =>
- void generateToday()} > + {t('actions.generate')} + = ({ workspacePath }) =>
{error &&
{error}
} - {writing && ( - - )} - {!selectedLetter ? ( !writing && ( void generateToday()} generating={writing} /> @@ -581,7 +584,6 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => onAccept={(candidate) => void applyReceipt(candidate, 'accept')} onDismiss={(candidate) => void applyReceipt(candidate, 'dismiss')} onEdit={openEditReceipt} - onContinuation={(card, remind) => void updateContinuation(card, remind)} t={t} />
@@ -596,7 +598,6 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => pendingCount={pendingCount} canSeal={canSeal} onSeal={() => void sealLetter()} - onContinuation={(card, remind) => void updateContinuation(card, remind)} formatDate={formatDate} t={t} /> @@ -638,49 +639,6 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => type TFn = (key: string, options?: Record) => string; type FormatDateFn = (date: Date | number, options?: Intl.DateTimeFormatOptions) => string; -function WritingDesk({ startedAtMs, t }: { startedAtMs: number | null; t: TFn }) { - const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - const id = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(id); - }, []); - - const elapsedSeconds = startedAtMs ? Math.max(0, Math.floor((now - startedAtMs) / 1000)) : null; - const elapsedLabel = elapsedSeconds === null - ? null - : elapsedSeconds < 60 - ? t('writing.elapsed', { seconds: elapsedSeconds }) - : t('writing.elapsedMinutes', { - minutes: Math.floor(elapsedSeconds / 60), - seconds: elapsedSeconds % 60, - }); - - return ( -
-
- -
-

{t('writing.title')}

-

{t('writing.body')}

-
-
- -
- {t('writing.hint')} - {elapsedLabel && {elapsedLabel}} -
-
- ); -} - interface PaperScrollState { collapsed: boolean; progress: number; @@ -694,7 +652,6 @@ function LetterPaper({ pendingCount, canSeal, onSeal, - onContinuation, formatDate, t, }: { @@ -704,7 +661,6 @@ function LetterPaper({ pendingCount: number; canSeal: boolean; onSeal: () => void; - onContinuation: (card: DailyLetterContinuationCard, remindTomorrow: boolean) => void; formatDate: FormatDateFn; t: TFn; }) { @@ -746,7 +702,6 @@ function LetterPaper({ // keep the raw date key } const writtenAt = formatDate(new Date(letter.createdAtMs), { timeStyle: 'short' }); - const firstContinuation = letter.continuationCards[0] ?? null; return (