diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index 267adbb..60a878d 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -362,6 +362,16 @@ public partial class App : System.Windows.Application
// L24-3: 자주 틀리는 맞춤법 (prefix=spell)
commandResolver.RegisterHandler(new SpellHandler());
+ // ─── L25: 개인 생산성 강화 ─────────────────────────────────────────────
+ // L25-1: 로컬 연락처 관리 (prefix=contact)
+ commandResolver.RegisterHandler(new ContactHandler());
+ // L25-2: 오늘 특정 시각 알림 (prefix=remind)
+ commandResolver.RegisterHandler(new RemindHandler());
+ // L25-3: 자주 쓰는 업무 문구 (prefix=phrase)
+ commandResolver.RegisterHandler(new PhraseHandler());
+ // L25-4: 오늘 업무 통합 뷰 (prefix=today)
+ commandResolver.RegisterHandler(new TodayHandler());
+
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
diff --git a/src/AxCopilot/Handlers/ContactHandler.cs b/src/AxCopilot/Handlers/ContactHandler.cs
new file mode 100644
index 0000000..124260a
--- /dev/null
+++ b/src/AxCopilot/Handlers/ContactHandler.cs
@@ -0,0 +1,311 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L25-1: 로컬 연락처 관리. "contact" 프리픽스로 사용합니다.
+///
+/// 예: contact → 전체 연락처 목록
+/// contact 홍길동 → 이름 검색
+/// contact add 홍길동 개발팀 010-1234-5678 hong@company.com → 추가
+/// contact del 홍길동 → 삭제
+/// contact <부서명> → 부서 필터
+/// Enter → 이메일 또는 전화번호 클립보드 복사
+/// 저장: %APPDATA%\AxCopilot\contacts.json
+///
+public class ContactHandler : IActionHandler
+{
+ public string? Prefix => "contact";
+
+ public PluginMetadata Metadata => new(
+ "연락처",
+ "로컬 연락처 관리 — 추가 · 검색 · 삭제 · 복사",
+ "1.0",
+ "AX");
+
+ private static readonly string DataPath = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "AxCopilot", "contacts.json");
+
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ private sealed class Contact
+ {
+ [JsonPropertyName("name")] public string Name { get; set; } = "";
+ [JsonPropertyName("dept")] public string Dept { get; set; } = "";
+ [JsonPropertyName("phone")] public string Phone { get; set; } = "";
+ [JsonPropertyName("email")] public string Email { get; set; } = "";
+ [JsonPropertyName("memo")] public string Memo { get; set; } = "";
+ }
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+ var contacts = LoadContacts();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem(
+ $"연락처 {contacts.Count}개",
+ "contact add 이름 부서 010-xxxx-xxxx email@company.com",
+ null, null, Symbol: "\uE8D4"));
+
+ if (contacts.Count == 0)
+ {
+ items.Add(new LauncherItem("저장된 연락처가 없습니다",
+ "contact add 이름 부서 전화 이메일 로 추가하세요",
+ null, null, Symbol: "\uE946"));
+ return Task.FromResult>(items);
+ }
+
+ foreach (var c in contacts)
+ items.Add(MakeContactItem(c));
+
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ // add 명령
+ if (sub == "add")
+ {
+ var addPart = parts.Length > 1 ? parts[1].Trim() : "";
+ if (string.IsNullOrWhiteSpace(addPart))
+ {
+ items.Add(new LauncherItem("이름을 입력하세요",
+ "예: contact add 홍길동 개발팀 010-1234-5678 hong@company.com",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ var (name, dept, phone, email, memo) = ParseAddArgs(addPart);
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ items.Add(new LauncherItem("이름을 입력하세요",
+ "예: contact add 홍길동 개발팀 010-1234-5678 hong@company.com",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ var encoded = $"{name}|{dept}|{phone}|{email}|{memo}";
+ items.Add(new LauncherItem(
+ $"연락처 추가: {name}",
+ $"{dept} {phone} {email} · Enter로 추가",
+ null, ("add", encoded), Symbol: "\uE710"));
+ return Task.FromResult>(items);
+ }
+
+ // del 명령
+ if (sub is "del" or "delete" or "remove")
+ {
+ var delName = parts.Length > 1 ? parts[1].Trim() : "";
+ if (string.IsNullOrWhiteSpace(delName))
+ {
+ items.Add(new LauncherItem("삭제할 이름을 입력하세요",
+ "예: contact del 홍길동",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ var matches = contacts.Where(c =>
+ c.Name.Contains(delName, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (matches.Count == 0)
+ {
+ items.Add(new LauncherItem($"'{delName}' 연락처를 찾을 수 없습니다", "",
+ null, null, Symbol: "\uE783"));
+ }
+ else
+ {
+ foreach (var c in matches)
+ items.Add(new LauncherItem(
+ $"{c.Name} 삭제",
+ $"{c.Dept} {c.Phone} {c.Email} · Enter로 삭제",
+ null, ("del", c.Name), Symbol: "\uE8D4"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // 검색: 이름, 부서, 메모 포함
+ var byStartsWith = contacts
+ .Where(c => c.Name.StartsWith(q, StringComparison.OrdinalIgnoreCase) ||
+ c.Dept.StartsWith(q, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ var byContains = contacts
+ .Where(c => !byStartsWith.Contains(c) &&
+ (c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+ c.Dept.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+ c.Memo.Contains(q, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+
+ var results = byStartsWith.Concat(byContains).ToList();
+
+ if (results.Count > 0)
+ {
+ items.Add(new LauncherItem($"'{q}' 검색 결과 {results.Count}개", "",
+ null, null, Symbol: "\uE8D4"));
+ foreach (var c in results)
+ items.Add(MakeContactItem(c));
+ }
+ else
+ {
+ items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
+ "contact add 로 새 연락처를 추가하세요",
+ null, null, Symbol: "\uE783"));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("copy_email", string email) when !string.IsNullOrWhiteSpace(email):
+ CopyToClipboard(email);
+ NotificationService.Notify("연락처", $"{email} 복사");
+ break;
+
+ case ("copy_phone", string phone) when !string.IsNullOrWhiteSpace(phone):
+ CopyToClipboard(phone);
+ NotificationService.Notify("연락처", $"{phone} 복사");
+ break;
+
+ case ("copy", string text) when !string.IsNullOrWhiteSpace(text):
+ CopyToClipboard(text);
+ NotificationService.Notify("연락처", $"{text} 복사");
+ break;
+
+ case ("add", string encoded):
+ var addParts = encoded.Split('|');
+ if (addParts.Length >= 5)
+ {
+ var contact = new Contact
+ {
+ Name = addParts[0],
+ Dept = addParts[1],
+ Phone = addParts[2],
+ Email = addParts[3],
+ Memo = addParts[4],
+ };
+ var list = LoadContacts();
+ list.RemoveAll(c => c.Name == contact.Name);
+ list.Add(contact);
+ SaveContacts(list);
+ NotificationService.Notify("연락처", $"{contact.Name} 추가됨");
+ }
+ break;
+
+ case ("del", string name):
+ var contacts = LoadContacts();
+ var removed = contacts.RemoveAll(c => c.Name == name);
+ if (removed > 0)
+ {
+ SaveContacts(contacts);
+ NotificationService.Notify("연락처", $"{name} 삭제됨");
+ }
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 저장/불러오기 ─────────────────────────────────────────────────────────
+
+ private static List LoadContacts()
+ {
+ try
+ {
+ if (!System.IO.File.Exists(DataPath)) return new List();
+ var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
+ return JsonSerializer.Deserialize>(json, JsonOpts) ?? new List();
+ }
+ catch { return new List(); }
+ }
+
+ private static void SaveContacts(List contacts)
+ {
+ try
+ {
+ System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
+ System.IO.File.WriteAllText(DataPath,
+ JsonSerializer.Serialize(contacts, JsonOpts),
+ System.Text.Encoding.UTF8);
+ }
+ catch { /* 비핵심 */ }
+ }
+
+ // ── 헬퍼 ─────────────────────────────────────────────────────────────────
+
+ private static LauncherItem MakeContactItem(Contact c)
+ {
+ var title = $"{c.Name}";
+ if (!string.IsNullOrWhiteSpace(c.Dept)) title += $" · {c.Dept}";
+ var sub = "";
+ if (!string.IsNullOrWhiteSpace(c.Phone)) sub += c.Phone;
+ if (!string.IsNullOrWhiteSpace(c.Email)) sub += (sub.Length > 0 ? " | " : "") + c.Email;
+
+ // 복사 우선순위: email > phone > name
+ (string action, string value) data;
+ if (!string.IsNullOrWhiteSpace(c.Email))
+ data = ("copy_email", c.Email);
+ else if (!string.IsNullOrWhiteSpace(c.Phone))
+ data = ("copy_phone", c.Phone);
+ else
+ data = ("copy", c.Name);
+
+ return new LauncherItem(title, sub, null, data, Symbol: "\uE8D4");
+ }
+
+ private static (string name, string dept, string phone, string email, string memo)
+ ParseAddArgs(string addPart)
+ {
+ var tokens = addPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length == 0) return ("", "", "", "", "");
+
+ var name = tokens[0];
+ var dept = "";
+ var phone = "";
+ var email = "";
+ var memoTokens = new List();
+
+ for (var i = 1; i < tokens.Length; i++)
+ {
+ var t = tokens[i];
+ if (string.IsNullOrWhiteSpace(phone) && IsPhoneLike(t))
+ phone = t;
+ else if (string.IsNullOrWhiteSpace(email) && t.Contains('@'))
+ email = t;
+ else if (string.IsNullOrWhiteSpace(dept) && i == 1)
+ dept = t;
+ else
+ memoTokens.Add(t);
+ }
+
+ return (name, dept, phone, email, string.Join(" ", memoTokens));
+ }
+
+ private static bool IsPhoneLike(string s)
+ {
+ var stripped = s.Replace("-", "").Replace(" ", "");
+ return stripped.Length >= 9 && stripped.All(c => char.IsDigit(c));
+ }
+
+ private static void CopyToClipboard(string text)
+ {
+ try
+ {
+ Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ }
+ catch { }
+ }
+}
diff --git a/src/AxCopilot/Handlers/PhraseHandler.cs b/src/AxCopilot/Handlers/PhraseHandler.cs
new file mode 100644
index 0000000..d2b6de7
--- /dev/null
+++ b/src/AxCopilot/Handlers/PhraseHandler.cs
@@ -0,0 +1,215 @@
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L25-3: 자주 쓰는 업무 문구 모음. "phrase" 프리픽스로 사용합니다.
+///
+/// 예: phrase → 카테고리 목록
+/// phrase 인사 → 인사 문구 목록
+/// phrase 보고 → 보고/발표 문구
+/// phrase 요청 → 요청/협조 문구
+/// phrase 마무리 → 마무리/감사 문구
+/// phrase <검색어> → 전체 검색
+/// Enter → 문구 클립보드 복사
+///
+public class PhraseHandler : IActionHandler
+{
+ public string? Prefix => "phrase";
+
+ public PluginMetadata Metadata => new(
+ "업무 문구",
+ "자주 쓰는 업무 문구 — 인사·보고·요청·마무리·승인·회의·사과",
+ "1.0",
+ "AX");
+
+ private sealed record PhraseEntry(string Text, string Category, string CategoryKey);
+
+ private static readonly List _phrases = BuildPhrases();
+
+ private static readonly (string Key, string Display, string[] Aliases)[] _categories =
+ [
+ ("greeting", "인사", ["인사", "greeting"]),
+ ("report", "보고", ["보고", "보고서", "report"]),
+ ("request", "요청", ["요청", "협조", "request"]),
+ ("closing", "마무리", ["마무리", "감사", "closing"]),
+ ("approval", "승인결재", ["승인", "결재", "approval"]),
+ ("meeting", "회의", ["회의", "미팅", "meeting"]),
+ ("apology", "사과지연", ["사과", "지연", "사죄", "apology"]),
+ ];
+
+ private static List BuildPhrases()
+ {
+ var list = new List();
+
+ // 인사 (greeting)
+ void G(string t) => list.Add(new PhraseEntry(t, "인사", "greeting"));
+ G("안녕하세요. [이름]입니다.");
+ G("수고 많으십니다.");
+ G("오랜만에 연락드립니다.");
+ G("처음 뵙겠습니다. 잘 부탁드립니다.");
+ G("바쁘신 와중에 연락드려 죄송합니다.");
+ G("항상 감사드립니다.");
+ G("늦은 시간에 연락드려 죄송합니다.");
+ G("좋은 하루 보내세요.");
+ G("주말 잘 보내세요.");
+ G("휴가 잘 다녀오세요.");
+
+ // 보고 (report)
+ void R(string t) => list.Add(new PhraseEntry(t, "보고", "report"));
+ R("말씀하신 대로 처리하겠습니다.");
+ R("현재 검토 중에 있습니다.");
+ R("확인 후 말씀드리겠습니다.");
+ R("금일 중으로 처리하겠습니다.");
+ R("해당 건은 [날짜]까지 완료 예정입니다.");
+ R("진행 상황을 공유드립니다.");
+ R("결과 보고드립니다.");
+ R("관련하여 추가 검토가 필요합니다.");
+ R("이슈 사항이 발생하여 공유드립니다.");
+ R("문제없이 완료되었습니다.");
+
+ // 요청 (request)
+ void Req(string t) => list.Add(new PhraseEntry(t, "요청", "request"));
+ Req("검토 부탁드립니다.");
+ Req("회신 부탁드립니다.");
+ Req("확인 부탁드립니다.");
+ Req("협조 부탁드립니다.");
+ Req("아래 내용 확인 후 승인 부탁드립니다.");
+ Req("첨부 파일 확인 부탁드립니다.");
+ Req("가능한 빠른 시일 내 처리 부탁드립니다.");
+ Req("[날짜]까지 회신 주시면 감사하겠습니다.");
+ Req("필요한 사항 있으시면 알려주세요.");
+ Req("문의 사항은 언제든지 연락 주세요.");
+
+ // 마무리 (closing)
+ void C(string t) => list.Add(new PhraseEntry(t, "마무리", "closing"));
+ C("감사합니다.");
+ C("수고하셨습니다.");
+ C("도움 주셔서 감사합니다.");
+ C("빠른 처리 감사드립니다.");
+ C("앞으로도 잘 부탁드립니다.");
+ C("이상입니다.");
+ C("이상으로 보고를 마치겠습니다.");
+ C("궁금하신 점은 언제든 문의 주세요.");
+ C("좋은 결과 있기를 바랍니다.");
+ C("다시 한번 감사드립니다.");
+
+ // 승인결재 (approval)
+ void A(string t) => list.Add(new PhraseEntry(t, "승인결재", "approval"));
+ A("검토 후 승인 부탁드립니다.");
+ A("위 내용으로 진행해도 될까요?");
+ A("담당자 확인 후 피드백 주세요.");
+ A("위와 같이 결정되었음을 알려드립니다.");
+ A("아래와 같이 변경합니다.");
+ A("이의 없으시면 그대로 진행하겠습니다.");
+ A("검토 의견 부탁드립니다.");
+
+ // 회의 (meeting)
+ void M(string t) => list.Add(new PhraseEntry(t, "회의", "meeting"));
+ M("오늘 회의 내용을 공유드립니다.");
+ M("다음 회의는 [날짜]에 진행 예정입니다.");
+ M("회의 참석 부탁드립니다.");
+ M("회의 장소 및 일정을 안내드립니다.");
+ M("회의록 공유드립니다.");
+ M("다음 주 [요일] [시간]에 미팅 가능하신가요?");
+ M("일정 조율 부탁드립니다.");
+ M("화상 회의 링크 공유드립니다.");
+ M("회의 아젠다 공유드립니다.");
+
+ // 사과/지연 (apology)
+ void Ap(string t) => list.Add(new PhraseEntry(t, "사과지연", "apology"));
+ Ap("처리가 늦어져 죄송합니다.");
+ Ap("불편을 드려 죄송합니다.");
+ Ap("확인이 늦었습니다. 죄송합니다.");
+ Ap("오류가 발생하여 사과드립니다.");
+ Ap("빠른 처리가 어려운 점 양해 부탁드립니다.");
+ Ap("답변이 늦어 죄송합니다.");
+
+ return list;
+ }
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ // 카테고리 목록 표시
+ items.Add(new LauncherItem(
+ "업무 문구 카테고리",
+ "phrase <카테고리> 또는 phrase <검색어>",
+ null, null, Symbol: "\uE8A0"));
+
+ foreach (var (key, display, _) in _categories)
+ {
+ var count = _phrases.Count(p => p.CategoryKey == key);
+ items.Add(new LauncherItem(
+ $"{display} ({count}개)",
+ $"phrase {display} → 문구 목록",
+ null, ("category", key), Symbol: "\uE8A0"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ var kw = q.ToLowerInvariant();
+
+ // 카테고리 키워드 매칭
+ foreach (var (key, display, aliases) in _categories)
+ {
+ if (aliases.Any(a => a.Equals(q, StringComparison.OrdinalIgnoreCase) ||
+ a.Contains(q, StringComparison.OrdinalIgnoreCase)))
+ {
+ var catPhrases = _phrases.Where(p => p.CategoryKey == key).ToList();
+ items.Add(new LauncherItem(
+ $"{display} ({catPhrases.Count}개)",
+ "Enter → 클립보드 복사",
+ null, null, Symbol: "\uE8A0"));
+ foreach (var p in catPhrases)
+ items.Add(new LauncherItem(p.Text, p.Category,
+ null, ("copy", p.Text), Symbol: "\uE8A0"));
+ return Task.FromResult>(items);
+ }
+ }
+
+ // 전체 검색
+ var results = _phrases
+ .Where(p => p.Text.Contains(q, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (results.Count > 0)
+ {
+ items.Add(new LauncherItem(
+ $"'{q}' 검색 결과 {results.Count}개",
+ "Enter → 클립보드 복사",
+ null, null, Symbol: "\uE8A0"));
+ foreach (var p in results)
+ items.Add(new LauncherItem(p.Text, p.Category,
+ null, ("copy", p.Text), Symbol: "\uE8A0"));
+ }
+ else
+ {
+ items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
+ "카테고리: 인사 / 보고 / 요청 / 마무리 / 승인결재 / 회의 / 사과지연",
+ null, null, Symbol: "\uE783"));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy", string text) && !string.IsNullOrWhiteSpace(text))
+ {
+ try
+ {
+ Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("문구", "클립보드에 복사했습니다.");
+ }
+ catch { }
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AxCopilot/Handlers/RemindHandler.cs b/src/AxCopilot/Handlers/RemindHandler.cs
new file mode 100644
index 0000000..1e1af86
--- /dev/null
+++ b/src/AxCopilot/Handlers/RemindHandler.cs
@@ -0,0 +1,288 @@
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L25-2: 오늘 특정 시각 알림. "remind" 프리픽스로 사용합니다.
+///
+/// 예: remind → 오늘 등록된 알림 목록
+/// remind 15:00 보고서 제출 → 오후 3시에 알림
+/// remind 오후3시 팀장보고 → 한국어 시각 파싱
+/// remind del 1 → 1번 알림 취소
+/// remind clear → 지난 알림 정리
+///
+public class RemindHandler : IActionHandler
+{
+ public string? Prefix => "remind";
+
+ public PluginMetadata Metadata => new(
+ "알림",
+ "오늘 특정 시각 알림 — 시간 설정 · 취소 · 목록",
+ "1.0",
+ "AX");
+
+ private sealed class RemindEntry
+ {
+ public int Id { get; set; }
+ public DateTime Time { get; set; }
+ public string Message { get; set; } = "";
+ public bool Fired { get; set; }
+ public CancellationTokenSource? Cts { get; set; }
+ }
+
+ private static readonly List _reminders = [];
+ private static readonly Dictionary _ctsDic = [];
+ private static readonly object _lock = new();
+ private static int _nextId = 1;
+
+ // 외부(TodayHandler)에서 오늘 알림 목록 조회용
+ internal static List<(DateTime Time, string Message)> GetTodayReminders()
+ {
+ var today = DateTime.Today;
+ lock (_lock)
+ {
+ return _reminders
+ .Where(r => !r.Fired && r.Time.Date == today)
+ .Select(r => (r.Time, r.Message))
+ .OrderBy(r => r.Time)
+ .ToList();
+ }
+ }
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ List pending;
+ lock (_lock)
+ pending = _reminders.Where(r => !r.Fired).OrderBy(r => r.Time).ToList();
+
+ if (pending.Count == 0)
+ {
+ items.Add(new LauncherItem("등록된 알림 없음",
+ "remind HH:mm 메시지 또는 remind 오후3시 메시지",
+ null, null, Symbol: "\uE787"));
+ }
+ else
+ {
+ items.Add(new LauncherItem($"알림 {pending.Count}개",
+ "remind <시각> <메시지> / remind del <번호>",
+ null, null, Symbol: "\uE787"));
+ foreach (var r in pending)
+ {
+ var left = r.Time > DateTime.Now
+ ? FormatLeft(r.Time - DateTime.Now)
+ : "시간 지남";
+ items.Add(new LauncherItem(
+ $"#{r.Id} {r.Time:HH:mm} — {r.Message}",
+ $"남은 시간: {left} · Enter로 취소",
+ null, ("del", r.Id.ToString()), Symbol: "\uE787"));
+ }
+ }
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ // del 명령
+ if (sub is "del" or "cancel" or "취소")
+ {
+ var idStr = parts.Length > 1 ? parts[1].Trim() : "";
+ if (!int.TryParse(idStr, out var delId))
+ {
+ items.Add(new LauncherItem("번호를 입력하세요",
+ "예: remind del 1",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+ RemindEntry? target;
+ lock (_lock) target = _reminders.FirstOrDefault(r => r.Id == delId);
+ if (target == null)
+ items.Add(new LauncherItem($"#{delId} 알림을 찾을 수 없습니다", "",
+ null, null, Symbol: "\uE783"));
+ else
+ items.Add(new LauncherItem(
+ $"#{delId} 알림 취소: {target.Time:HH:mm} {target.Message}",
+ "Enter로 취소합니다",
+ null, ("del", delId.ToString()), Symbol: "\uE787"));
+ return Task.FromResult>(items);
+ }
+
+ // clear 명령
+ if (sub == "clear")
+ {
+ items.Add(new LauncherItem("지난 알림 정리",
+ "발화된 알림을 목록에서 제거합니다 · Enter 실행",
+ null, ("clear", ""), Symbol: "\uE787"));
+ return Task.FromResult>(items);
+ }
+
+ // 시각 파싱 시도
+ var (timeStr, msgStr) = SplitTimeAndMessage(q);
+ if (!string.IsNullOrWhiteSpace(timeStr) && TryParseTime(timeStr, out var alarmTime))
+ {
+ var leftText = alarmTime > DateTime.Now
+ ? FormatLeft(alarmTime - DateTime.Now)
+ : "이미 지난 시각 (내일로 설정됩니다)";
+ var message = string.IsNullOrWhiteSpace(msgStr) ? "(메시지 없음)" : msgStr;
+ var encoded = $"{alarmTime:O}|{message}";
+ items.Add(new LauncherItem(
+ $"알림 설정: {alarmTime:HH:mm} — {message}",
+ $"{leftText} · Enter로 설정",
+ null, ("set", encoded), Symbol: "\uE787"));
+ }
+ else
+ {
+ items.Add(new LauncherItem($"시각 파싱 실패: '{q}'",
+ "예: remind 15:00 보고서 제출 / remind 오후3시 팀장보고",
+ null, null, Symbol: "\uE783"));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("set", string encoded):
+ {
+ var idx = encoded.IndexOf('|');
+ if (idx < 0) break;
+ var timeIso = encoded[..idx];
+ var message = encoded[(idx + 1)..];
+ if (!DateTime.TryParse(timeIso, null,
+ System.Globalization.DateTimeStyles.RoundtripKind, out var alarmTime))
+ break;
+
+ var cts = new CancellationTokenSource();
+ int id;
+ lock (_lock)
+ {
+ id = _nextId++;
+ var entry = new RemindEntry { Id = id, Time = alarmTime, Message = message, Cts = cts };
+ _reminders.Add(entry);
+ _ctsDic[id] = cts;
+ }
+ NotificationService.Notify("알림", $"#{id} {alarmTime:HH:mm} {message} 설정됨");
+ _ = RunReminderAsync(id, alarmTime, message, cts.Token);
+ break;
+ }
+
+ case ("del", string idStr) when int.TryParse(idStr, out var delId):
+ {
+ RemindEntry? entry;
+ lock (_lock)
+ {
+ entry = _reminders.FirstOrDefault(r => r.Id == delId);
+ if (entry != null) _reminders.Remove(entry);
+ if (_ctsDic.TryGetValue(delId, out var c)) { c.Cancel(); _ctsDic.Remove(delId); }
+ }
+ if (entry != null)
+ NotificationService.Notify("알림", $"#{delId} 알림 취소됨");
+ break;
+ }
+
+ case ("clear", _):
+ {
+ int cleared;
+ lock (_lock)
+ {
+ cleared = _reminders.RemoveAll(r => r.Fired);
+ }
+ NotificationService.Notify("알림", $"지난 알림 {cleared}개 정리됨");
+ break;
+ }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 알림 실행 ────────────────────────────────────────────────────────────
+
+ private static async Task RunReminderAsync(int id, DateTime at, string message, CancellationToken token)
+ {
+ try
+ {
+ var delay = at - DateTime.Now;
+ if (delay > TimeSpan.Zero)
+ await Task.Delay(delay, token);
+
+ lock (_lock)
+ {
+ var entry = _reminders.FirstOrDefault(r => r.Id == id);
+ if (entry != null) entry.Fired = true;
+ _ctsDic.Remove(id);
+ }
+ NotificationService.Notify("⏰ 알림", $"{at:HH:mm} {message}");
+ }
+ catch (OperationCanceledException) { }
+ }
+
+ // ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
+
+ private static (string timeStr, string message) SplitTimeAndMessage(string q)
+ {
+ var sp = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ if (sp.Length == 0) return ("", "");
+ return (sp[0], sp.Length > 1 ? sp[1] : "");
+ }
+
+ private static bool TryParseTime(string s, out DateTime result)
+ {
+ result = DateTime.MinValue;
+ s = s.Trim();
+
+ // HH:mm 또는 H:mm
+ if (System.Text.RegularExpressions.Regex.IsMatch(s, @"^\d{1,2}:\d{2}$"))
+ {
+ var colonParts = s.Split(':');
+ if (int.TryParse(colonParts[0], out var h) &&
+ int.TryParse(colonParts[1], out var m) &&
+ h is >= 0 and <= 23 && m is >= 0 and <= 59)
+ {
+ result = DateTime.Today.AddHours(h).AddMinutes(m);
+ if (result <= DateTime.Now) result = result.AddDays(1);
+ return true;
+ }
+ }
+
+ // 한국어 시각: 오전N시M분, 오후N시M분, N시M분, 오전N시, 오후N시, N시
+ var korMatch = System.Text.RegularExpressions.Regex.Match(s,
+ @"^(오전|오후)?(\d{1,2})시((\d{1,2})분)?$");
+ if (korMatch.Success)
+ {
+ var meridiem = korMatch.Groups[1].Value;
+ if (!int.TryParse(korMatch.Groups[2].Value, out var h)) return false;
+ var minStr = korMatch.Groups[4].Value;
+ var m = string.IsNullOrEmpty(minStr) ? 0 : int.TryParse(minStr, out var mp) ? mp : 0;
+
+ if (meridiem == "오후" && h < 12) h += 12;
+ if (meridiem == "오전" && h == 12) h = 0;
+
+ if (h is < 0 or > 23 || m is < 0 or > 59) return false;
+
+ result = DateTime.Today.AddHours(h).AddMinutes(m);
+ if (result <= DateTime.Now) result = result.AddDays(1);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static string FormatLeft(TimeSpan ts)
+ {
+ if (ts.TotalSeconds < 60)
+ return $"{(int)ts.TotalSeconds}초 후";
+ if (ts.TotalMinutes < 60)
+ return $"{(int)ts.TotalMinutes}분 후";
+ var h = (int)ts.TotalHours;
+ var m = (int)(ts.TotalMinutes - h * 60);
+ return m > 0 ? $"{h}시간 {m}분 후" : $"{h}시간 후";
+ }
+}
diff --git a/src/AxCopilot/Handlers/TodayHandler.cs b/src/AxCopilot/Handlers/TodayHandler.cs
new file mode 100644
index 0000000..68a6579
--- /dev/null
+++ b/src/AxCopilot/Handlers/TodayHandler.cs
@@ -0,0 +1,250 @@
+using System.Text.Json;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L25-4: 오늘 업무 통합 뷰. "today" 프리픽스로 사용합니다.
+///
+/// 예: today → 오늘 날짜/요일/공휴일 + 할일 + 알림 + 공휴일 현황
+///
+public class TodayHandler : IActionHandler
+{
+ public string? Prefix => "today";
+
+ public PluginMetadata Metadata => new(
+ "오늘",
+ "오늘 업무 통합 뷰 — 날짜·할일·알림·공휴일",
+ "1.0",
+ "AX");
+
+ private static readonly string TodoPath = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "AxCopilot", "todos.json");
+
+ private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"];
+
+ // 2025~2027 주요 공휴일 (CalHandler에 직접 접근 불가하므로 독립 정의)
+ private static readonly Dictionary Holidays = new()
+ {
+ // 2025
+ { new DateOnly(2025, 1, 1), "신정" },
+ { new DateOnly(2025, 1, 28), "설날연휴" },
+ { new DateOnly(2025, 1, 29), "설날" },
+ { new DateOnly(2025, 1, 30), "설날연휴" },
+ { new DateOnly(2025, 3, 1), "삼일절" },
+ { new DateOnly(2025, 3, 3), "대체공휴일" },
+ { new DateOnly(2025, 5, 5), "어린이날" },
+ { new DateOnly(2025, 5, 6), "부처님오신날" },
+ { new DateOnly(2025, 6, 6), "현충일" },
+ { new DateOnly(2025, 8, 15), "광복절" },
+ { new DateOnly(2025, 10, 3), "개천절" },
+ { new DateOnly(2025, 10, 5), "추석연휴" },
+ { new DateOnly(2025, 10, 6), "추석" },
+ { new DateOnly(2025, 10, 7), "추석연휴" },
+ { new DateOnly(2025, 10, 8), "대체공휴일" },
+ { new DateOnly(2025, 10, 9), "한글날" },
+ { new DateOnly(2025, 12, 25), "크리스마스" },
+
+ // 2026
+ { new DateOnly(2026, 1, 1), "신정" },
+ { new DateOnly(2026, 2, 17), "설날연휴" },
+ { new DateOnly(2026, 2, 18), "설날" },
+ { new DateOnly(2026, 2, 19), "설날연휴" },
+ { new DateOnly(2026, 3, 1), "삼일절" },
+ { new DateOnly(2026, 3, 2), "대체공휴일" },
+ { new DateOnly(2026, 5, 5), "어린이날" },
+ { new DateOnly(2026, 5, 24), "부처님오신날" },
+ { new DateOnly(2026, 5, 25), "대체공휴일" },
+ { new DateOnly(2026, 6, 6), "현충일" },
+ { new DateOnly(2026, 6, 8), "대체공휴일" },
+ { new DateOnly(2026, 8, 15), "광복절" },
+ { new DateOnly(2026, 8, 17), "대체공휴일" },
+ { new DateOnly(2026, 9, 24), "추석연휴" },
+ { new DateOnly(2026, 9, 25), "추석" },
+ { new DateOnly(2026, 9, 26), "추석연휴" },
+ { new DateOnly(2026, 10, 3), "개천절" },
+ { new DateOnly(2026, 10, 5), "대체공휴일" },
+ { new DateOnly(2026, 10, 9), "한글날" },
+ { new DateOnly(2026, 12, 25), "크리스마스" },
+
+ // 2027
+ { new DateOnly(2027, 1, 1), "신정" },
+ { new DateOnly(2027, 2, 7), "설날연휴" },
+ { new DateOnly(2027, 2, 8), "설날" },
+ { new DateOnly(2027, 2, 9), "설날연휴" },
+ { new DateOnly(2027, 3, 1), "삼일절" },
+ { new DateOnly(2027, 5, 5), "어린이날" },
+ { new DateOnly(2027, 5, 13), "부처님오신날" },
+ { new DateOnly(2027, 6, 6), "현충일" },
+ { new DateOnly(2027, 6, 7), "대체공휴일" },
+ { new DateOnly(2027, 8, 15), "광복절" },
+ { new DateOnly(2027, 8, 16), "대체공휴일" },
+ { new DateOnly(2027, 9, 13), "추석연휴" },
+ { new DateOnly(2027, 9, 14), "추석" },
+ { new DateOnly(2027, 9, 15), "추석연휴" },
+ { new DateOnly(2027, 10, 3), "개천절" },
+ { new DateOnly(2027, 10, 4), "대체공휴일" },
+ { new DateOnly(2027, 10, 9), "한글날" },
+ { new DateOnly(2027, 12, 25), "크리스마스" },
+ { new DateOnly(2027, 12, 27), "대체공휴일" },
+ };
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var items = new List();
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var now = DateTime.Now;
+
+ // ─── 항목1: 날짜 헤더 ────────────────────────────────────────────────
+ var dow = DayNames[(int)today.DayOfWeek];
+ var isHol = Holidays.TryGetValue(today, out var holName);
+ var isWknd = today.DayOfWeek == DayOfWeek.Saturday || today.DayOfWeek == DayOfWeek.Sunday;
+ string status;
+ if (isHol) status = $"공휴일 — {holName}";
+ else if (isWknd) status = "주말";
+ else status = "평일 (업무일)";
+
+ items.Add(new LauncherItem(
+ $"{today:yyyy년 MM월 dd일} ({dow}요일)",
+ status,
+ null, ("copy", $"{today:yyyy-MM-dd} ({dow}) {status}"),
+ Symbol: "\uE8BF"));
+
+ // ─── 항목2: 할일 ─────────────────────────────────────────────────────
+ var (pendingCount, recentTitles) = LoadPendingTodos();
+ var todoSub = pendingCount == 0
+ ? "미완료 할일 없음"
+ : string.Join(" / ", recentTitles.Take(3));
+ items.Add(new LauncherItem(
+ $"미완료 할일 {pendingCount}건",
+ todoSub,
+ null, null, Symbol: "\uE762"));
+
+ // ─── 항목3: 알림 ─────────────────────────────────────────────────────
+ var todayReminders = RemindHandler.GetTodayReminders();
+ if (todayReminders.Count == 0)
+ {
+ items.Add(new LauncherItem("오늘 알림 없음",
+ "remind HH:mm 메시지 로 알림을 설정하세요",
+ null, null, Symbol: "\uE787"));
+ }
+ else
+ {
+ var remindSub = string.Join(" / ",
+ todayReminders.Take(3).Select(r => $"{r.Time:HH:mm} {r.Message}"));
+ items.Add(new LauncherItem(
+ $"오늘 알림 {todayReminders.Count}건",
+ remindSub,
+ null, null, Symbol: "\uE787"));
+ }
+
+ // ─── 항목4: 다음 공휴일 ──────────────────────────────────────────────
+ var nextHol = Holidays.Keys
+ .Where(d => d > today)
+ .OrderBy(d => d)
+ .FirstOrDefault();
+
+ if (nextHol != default)
+ {
+ var diff = nextHol.DayNumber - today.DayNumber;
+ var nextDow = DayNames[(int)nextHol.DayOfWeek];
+ items.Add(new LauncherItem(
+ $"다음 공휴일: {nextHol:MM/dd} ({nextDow}) {Holidays[nextHol]}",
+ $"D-{diff}일",
+ null, ("copy", $"{nextHol:yyyy-MM-dd} {Holidays[nextHol]} D-{diff}"),
+ Symbol: "\uE787"));
+ }
+ else
+ {
+ items.Add(new LauncherItem("다음 공휴일 정보 없음", "",
+ null, null, Symbol: "\uE787"));
+ }
+
+ // ─── 항목5: 이번달 잔여 업무일 ───────────────────────────────────────
+ var lastDay = new DateOnly(today.Year, today.Month,
+ DateTime.DaysInMonth(today.Year, today.Month));
+ var remaining = CountWorkdaysFrom(today, lastDay);
+ var total = CountWorkdays(today.Year, today.Month);
+ items.Add(new LauncherItem(
+ $"이번달 잔여 업무일 {remaining}일",
+ $"{today.Year}년 {today.Month}월 총 업무일: {total}일",
+ null, null, Symbol: "\uE8BF"));
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy", string text) && !string.IsNullOrWhiteSpace(text))
+ {
+ try
+ {
+ Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("오늘", "클립보드에 복사했습니다.");
+ }
+ catch { }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 할일 파싱 ────────────────────────────────────────────────────────────
+
+ private static (int pending, List titles) LoadPendingTodos()
+ {
+ try
+ {
+ if (!System.IO.File.Exists(TodoPath)) return (0, []);
+ var json = System.IO.File.ReadAllText(TodoPath, System.Text.Encoding.UTF8);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Array) return (0, []);
+
+ var pending = 0;
+ var titles = new List();
+ foreach (var el in root.EnumerateArray())
+ {
+ var done = false;
+ if (el.TryGetProperty("done", out var doneProp))
+ done = doneProp.GetBoolean();
+ if (done) continue;
+
+ pending++;
+ if (titles.Count < 3 && el.TryGetProperty("text", out var textProp))
+ titles.Add(textProp.GetString() ?? "");
+ }
+ return (pending, titles);
+ }
+ catch { return (0, []); }
+ }
+
+ // ── 업무일 계산 ──────────────────────────────────────────────────────────
+
+ private static bool IsHoliday(DateOnly d) =>
+ Holidays.ContainsKey(d) ||
+ d.DayOfWeek == DayOfWeek.Saturday ||
+ d.DayOfWeek == DayOfWeek.Sunday;
+
+ private static int CountWorkdays(int year, int month)
+ {
+ var days = DateTime.DaysInMonth(year, month);
+ var count = 0;
+ for (var i = 1; i <= days; i++)
+ if (!IsHoliday(new DateOnly(year, month, i))) count++;
+ return count;
+ }
+
+ private static int CountWorkdaysFrom(DateOnly from, DateOnly to)
+ {
+ var count = 0;
+ var cur = from;
+ while (cur <= to)
+ {
+ if (!IsHoliday(cur)) count++;
+ cur = cur.AddDays(1);
+ }
+ return count;
+ }
+}