ai-worklog 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-worklog",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "AI 对话工作日志自动收集工具 —— 从 Claude Code / Codex 对话记录生成每日工作日志并推送到 GitLab",
5
5
  "bin": {
6
6
  "ai-worklog": "./bin/index.js"
@@ -19,6 +19,80 @@ from pathlib import Path
19
19
  from typing import Optional
20
20
 
21
21
 
22
+ # ─── TUI 输出 ────────────────────────────────────────────────────────────────
23
+
24
+ _COLOR = sys.stdout.isatty()
25
+
26
+ def _c(text: str, code: str) -> str:
27
+ return f"\033[{code}m{text}\033[0m" if _COLOR else text
28
+
29
+ def bold(t): return _c(t, "1")
30
+ def dim(t): return _c(t, "2")
31
+ def green(t): return _c(t, "32")
32
+ def cyan(t): return _c(t, "36")
33
+ def yellow(t): return _c(t, "33")
34
+ def gray(t): return _c(t, "90")
35
+ def red(t): return _c(t, "31")
36
+ def blue(t): return _c(t, "34")
37
+
38
+
39
+ def print_header(date_str: str, incremental: bool = False, prev_time: str = "") -> None:
40
+ line = f" ai-worklog · {date_str} "
41
+ w = max(len(line), 44)
42
+ print(f"\n╭{'─' * w}╮")
43
+ print(f"│{bold(line):<{w + (10 if _COLOR else 0)}}│")
44
+ print(f"╰{'─' * w}╯")
45
+ if incremental:
46
+ print(f"\n {yellow('↻')} 增量更新模式 {dim(f'上次生成于 {prev_time}')}")
47
+
48
+
49
+ def print_step(msg: str) -> None:
50
+ print(f"\n{bold(cyan('●'))} {bold(msg)}")
51
+
52
+
53
+ def print_ok(msg: str) -> None:
54
+ print(f" {green('✓')} {msg}")
55
+
56
+
57
+ def print_info(msg: str) -> None:
58
+ print(f" {dim('│')} {dim(msg)}")
59
+
60
+
61
+ def print_warn(msg: str) -> None:
62
+ print(f" {yellow('⚠')} {msg}", file=sys.stderr)
63
+
64
+
65
+ def print_fail(msg: str) -> None:
66
+ print(f" {red('✗')} {msg}", file=sys.stderr)
67
+
68
+
69
+ def print_project_tree(data: dict, is_sessions: bool = True) -> None:
70
+ """打印项目列表(树形)"""
71
+ if not data:
72
+ print(f" {gray('└─')} {gray('(无记录)')}")
73
+ return
74
+ items = list(data.items())
75
+ max_len = max(len(p) for p in data)
76
+ for i, (proj, val) in enumerate(items):
77
+ connector = gray("└─") if i == len(items) - 1 else gray("├─")
78
+ if is_sessions:
79
+ n_sessions = len(val)
80
+ n_turns = sum(len(t) for t in val)
81
+ stats = gray(f"{n_sessions} 对话 {n_turns} 轮")
82
+ else:
83
+ stats = gray(f"{len(val)} 条")
84
+ print(f" {connector} {bold(proj.ljust(max_len))} {stats}")
85
+
86
+
87
+ def print_footer(log_file: Path, total_projects: int, total_turns: int) -> None:
88
+ w = 46
89
+ print(f"\n{'─' * w}")
90
+ rel = str(log_file).replace(str(Path.home()), "~")
91
+ print(f" {green('✓')} {bold('完成')} {total_projects} 个项目 · {total_turns} 轮对话")
92
+ print(f" {dim(rel)}")
93
+ print(f"{'─' * w}\n")
94
+
95
+
22
96
  # ─── 配置 ───────────────────────────────────────────────────────────────────
23
97
 
24
98
  WORKLOG_CONFIG_FILE = Path.home() / ".config" / "worklog.json"
@@ -102,28 +176,42 @@ def utc_to_local_date(utc_ts: str) -> Optional[str]:
102
176
 
103
177
  # ─── Claude Code 数据收集 ─────────────────────────────────────────────────────
104
178
 
105
- def collect_claude_sessions(target_date: str) -> dict[str, list[list[tuple[str, str]]]]:
179
+ def collect_claude_sessions(
180
+ target_date: str,
181
+ prev_file_counts: Optional[dict[str, int]] = None,
182
+ ) -> tuple[dict[str, list[list[tuple[str, str]]]], dict[str, int]]:
106
183
  """
107
- 遍历 ~/.claude/projects/ 下所有 jsonl
108
- 按 message timestamp 过滤当天(UTC→本地),
109
- 每个 jsonl 文件作为一个对话,提取 (用户消息, Claude回复) 轮次对,
110
- 按项目名(cwd basename)分组。
184
+ 遍历 ~/.claude/projects/ 下所有 jsonl,提取当天的对话轮次对。
185
+
186
+ prev_file_counts: 上次生成时各文件已处理的轮次数。
187
+ 传入时只返回新增轮次(增量模式)。
111
188
 
112
- 返回: {project_name: [session1, session2, ...]}
113
- session = [(user_msg, assistant_msg), ...]
189
+ 返回:
190
+ sessions: {project_name: [session, ...]},session = [(user, assistant), ...]
191
+ file_counts: {jsonl_file_path: 本次处理的总轮次数}(用于保存 checkpoint)
114
192
  """
115
- results: dict[str, list[list[tuple[str, str]]]] = {}
193
+ sessions: dict[str, list[list[tuple[str, str]]]] = {}
194
+ file_counts: dict[str, int] = {}
116
195
 
117
196
  if not CLAUDE_PROJECTS_DIR.exists():
118
- return results
197
+ return sessions, file_counts
119
198
 
120
199
  for project_dir in CLAUDE_PROJECTS_DIR.iterdir():
121
200
  if not project_dir.is_dir():
122
201
  continue
123
202
  for jsonl_file in project_dir.glob("*.jsonl"):
124
- _parse_claude_jsonl(jsonl_file, target_date, results)
203
+ turns, cwd = _parse_claude_jsonl(jsonl_file, target_date)
204
+ file_key = str(jsonl_file)
205
+ file_counts[file_key] = len(turns)
125
206
 
126
- return results
207
+ prev = (prev_file_counts or {}).get(file_key, 0)
208
+ new_turns = turns[prev:] # 增量模式下只取新增部分;首次 prev=0 取全部
209
+
210
+ if new_turns:
211
+ project_name = os.path.basename(cwd) if cwd else "unknown"
212
+ sessions.setdefault(project_name, []).append(new_turns)
213
+
214
+ return sessions, file_counts
127
215
 
128
216
 
129
217
  def _extract_user_text(content) -> str:
@@ -153,14 +241,14 @@ def _extract_assistant_text(content) -> str:
153
241
 
154
242
 
155
243
  def _parse_claude_jsonl(
156
- jsonl_file: Path, target_date: str, results: dict[str, list[list[tuple[str, str]]]]
157
- ) -> None:
244
+ jsonl_file: Path, target_date: str
245
+ ) -> tuple[list[tuple[str, str]], str]:
158
246
  """
159
247
  解析单个 Claude Code jsonl 文件。
160
- 每个文件视为一个对话 session,提取当天的 (用户消息, Claude回复) 轮次对。
248
+ 返回 (当天所有轮次对, cwd)
161
249
  Claude 回复只取 text 块,跳过 thinking 和 tool_use。
162
250
  """
163
- session_turns: list[tuple[str, str]] = []
251
+ turns: list[tuple[str, str]] = []
164
252
  pending_user: str = ""
165
253
  pending_user_date: str = ""
166
254
  pending_assistant: str = ""
@@ -190,7 +278,7 @@ def _parse_claude_jsonl(
190
278
 
191
279
  # 保存上一个 turn(日期匹配才入列)
192
280
  if pending_user and pending_user_date == target_date:
193
- session_turns.append((pending_user, pending_assistant))
281
+ turns.append((pending_user, pending_assistant))
194
282
 
195
283
  # 开始新 turn
196
284
  ts = entry.get("timestamp", "")
@@ -205,19 +293,17 @@ def _parse_claude_jsonl(
205
293
  entry.get("message", {}).get("content", [])
206
294
  )
207
295
  if assistant_text:
208
- pending_assistant = assistant_text # 用最新文字覆盖(多步工具调用后取最终回复)
296
+ pending_assistant = assistant_text # 多步工具调用后取最终回复
209
297
 
210
298
  # 最后一个 pending turn
211
299
  if pending_user and pending_user_date == target_date:
212
- session_turns.append((pending_user, pending_assistant))
213
-
214
- if session_turns:
215
- project_name = os.path.basename(cwd) if cwd else "unknown"
216
- results.setdefault(project_name, []).append(session_turns)
300
+ turns.append((pending_user, pending_assistant))
217
301
 
218
302
  except Exception as e:
219
303
  print(f"警告: 解析 {jsonl_file} 失败: {e}", file=sys.stderr)
220
304
 
305
+ return turns, cwd
306
+
221
307
 
222
308
  # ─── Codex 数据收集 ───────────────────────────────────────────────────────────
223
309
 
@@ -455,10 +541,93 @@ def save_log(target_date: str, content: str) -> Path:
455
541
  log_dir.mkdir(parents=True, exist_ok=True)
456
542
  log_file = log_dir / f"{target_date}.md"
457
543
  log_file.write_text(content, encoding="utf-8")
458
- print(f"日志已保存: {log_file}")
459
544
  return log_file
460
545
 
461
546
 
547
+ # ─── Checkpoint(增量更新支持)────────────────────────────────────────────────
548
+
549
+ def _checkpoint_path(target_date: str) -> Path:
550
+ year = target_date[:4]
551
+ return LOGS_DIR / year / f"{target_date}.meta.json"
552
+
553
+
554
+ def load_checkpoint(target_date: str) -> Optional[dict]:
555
+ """读取上次生成的 checkpoint,不存在或解析失败返回 None"""
556
+ path = _checkpoint_path(target_date)
557
+ if not path.exists():
558
+ return None
559
+ try:
560
+ return json.loads(path.read_text())
561
+ except Exception:
562
+ return None
563
+
564
+
565
+ def save_checkpoint(target_date: str, file_counts: dict[str, int]) -> None:
566
+ """保存本次处理的 checkpoint(各文件已处理轮次数)"""
567
+ path = _checkpoint_path(target_date)
568
+ path.parent.mkdir(parents=True, exist_ok=True)
569
+ data = {
570
+ "generated_at": datetime.now().isoformat(timespec="seconds"),
571
+ "file_counts": file_counts,
572
+ }
573
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
574
+
575
+
576
+ def generate_incremental_update(
577
+ target_date: str,
578
+ existing_log: str,
579
+ new_claude_data: dict[str, list[list[tuple[str, str]]]],
580
+ new_codex_data: dict[str, list[str]],
581
+ ) -> str:
582
+ """基于已有日志 + 新增对话,调用 AI 生成更新后的完整日志"""
583
+
584
+ def trim(text: str, max_chars: int) -> str:
585
+ return text if len(text) <= max_chars else text[:max_chars] + "…"
586
+
587
+ project_sections = []
588
+ for proj_name, sessions in new_claude_data.items():
589
+ parts = []
590
+ for si, turns in enumerate(sessions, 1):
591
+ parts.append(f" [新增对话 {si}]({len(turns)} 轮)")
592
+ for user_msg, asst_msg in turns:
593
+ parts.append(f" [用户] {trim(user_msg, 200)}")
594
+ if asst_msg:
595
+ parts.append(f" [Claude] {trim(asst_msg, 150)}")
596
+ total_turns = sum(len(t) for t in sessions)
597
+ project_sections.append(
598
+ f"项目: {proj_name}(新增 {len(sessions)} 个对话 {total_turns} 轮)\n" + "\n".join(parts)
599
+ )
600
+ for proj_name, msgs in new_codex_data.items():
601
+ parts = [f" [Codex] {trim(m, 200)}" for m in msgs]
602
+ project_sections.append(f"项目: {proj_name}(Codex 新增 {len(msgs)} 条)\n" + "\n".join(parts))
603
+
604
+ new_data_text = "\n\n".join(project_sections)
605
+ now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
606
+
607
+ prompt = f"""以下是今天已生成的工作日志:
608
+
609
+ {existing_log}
610
+
611
+ ---
612
+ 以下是在上次生成之后新增的 AI 对话记录:
613
+
614
+ {new_data_text}
615
+
616
+ 请在已有日志基础上进行更新,要求:
617
+ 1. 更新「今日概览」中的统计数字(累计到最新)
618
+ 2. 在对应项目的「主要工作」中追加新工作内容(保留原有内容,不要重复)
619
+ 3. 如有新项目,新增对应章节
620
+ 4. 更新「今日总结」以反映全天整体工作
621
+ 5. 将页脚的生成时间改为 {now_str}
622
+ 6. 只输出完整的更新后日志,不要加任何额外说明"""
623
+
624
+ try:
625
+ return _call_api_claude_cli(prompt)
626
+ except Exception as e:
627
+ print(f"API 请求失败: {e}", file=sys.stderr)
628
+ return existing_log # 失败时保留原有日志
629
+
630
+
462
631
  GITLAB_HOST = "gitcode.lingjingai.cn"
463
632
 
464
633
 
@@ -540,67 +709,67 @@ def git_commit_and_push(log_file: Path, target_date: str, push: bool = True) ->
540
709
  env["LC_ALL"] = "C" # 强制英文输出,保证字符串匹配不受本地化影响
541
710
  r = subprocess.run(cmd, cwd=REPO_DIR, capture_output=True, text=True, env=env)
542
711
  if check_err and r.returncode != 0:
543
- print(f"命令失败: {' '.join(cmd)}\n{r.stderr.strip()}", file=sys.stderr)
712
+ print_fail(f"{' '.join(cmd[:2])}: {r.stderr.strip()}")
544
713
  return r
545
714
 
715
+ print_step("同步 Git")
716
+
546
717
  # ── 1. 初始化 git 仓库 ──────────────────────────────────────────────────
547
718
  is_git = run(["git", "rev-parse", "--git-dir"], check_err=False).returncode == 0
548
719
  if not is_git:
549
- print("Git: 初始化仓库...")
720
+ print_info("初始化仓库...")
550
721
  if run(["git", "init"]).returncode != 0:
551
722
  return
552
723
  run(["git", "checkout", "-b", "main"], check_err=False)
724
+ print_ok("git init")
553
725
 
554
726
  # ── 2. 检查并创建 GitLab 远程(无需 glab)────────────────────────────────
555
727
  has_remote = run(["git", "remote", "get-url", "origin"], check_err=False).returncode == 0
556
728
  if not has_remote:
557
729
  repo_name = REPO_DIR.name
558
- print(f"GitLab: 创建公开项目 {repo_name} ...")
730
+ print_info(f"GitLab 创建公开项目 {repo_name} ...")
559
731
  try:
560
732
  cfg = load_gitlab_config()
561
733
  ssh_url = gitlab_create_project(cfg, repo_name)
562
734
  run(["git", "remote", "add", "origin", ssh_url])
563
- print(f"GitLab: 项目已创建 → {ssh_url}")
735
+ print_ok(f"远程仓库 → {ssh_url}")
564
736
  except Exception as e:
565
- print(f"GitLab 创建失败: {e}", file=sys.stderr)
566
- print("请手动设置 remote: git remote add origin <url>", file=sys.stderr)
737
+ print_fail(f"GitLab 创建失败: {e}")
738
+ print_info("请手动: git remote add origin <url>")
567
739
  return
568
740
 
569
741
  # ── 3. 确保有初始提交 ─────────────────────────────────────────────────────
570
742
  if run(["git", "rev-parse", "HEAD"], check_err=False).returncode != 0:
571
- print("Git: 创建初始提交...")
572
743
  run(["git", "add", "-A"])
573
744
  run(["git", "commit", "-m", "init: 初始化工作日志仓库"])
574
745
 
575
746
  # ── 4. 提交日志 ───────────────────────────────────────────────────────────
576
747
  rel_path = log_file.relative_to(REPO_DIR)
577
- print(f"Git: 添加 {rel_path}")
578
748
  if run(["git", "add", str(rel_path)]).returncode != 0:
579
749
  return
580
750
 
581
751
  commit_msg = f"docs: 工作日志 {target_date}"
582
- print(f"Git: 提交 '{commit_msg}'")
583
752
  r = run(["git", "commit", "-m", commit_msg], check_err=False)
584
753
  if r.returncode != 0:
585
754
  if "nothing to commit" in r.stdout + r.stderr:
586
- print("Git: 无变更,跳过提交(已在初始提交中)")
755
+ print_info("无新变更,跳过提交")
587
756
  else:
588
- print(f"Git commit 失败: {r.stderr.strip()}", file=sys.stderr)
757
+ print_fail(f"commit 失败: {r.stderr.strip()}")
589
758
  return
590
- # nothing to commit 不代表不需要 push,继续走推送流程
759
+ else:
760
+ print_ok(f"commit: {dim(commit_msg)}")
591
761
 
592
762
  # ── 5. 推送 ────────────────────────────────────────────────────────────────
593
763
  if push:
594
- print("Git: 推送到远程...")
595
764
  r = run(["git", "push", "--set-upstream", "origin", "main"], check_err=False)
596
765
  if r.returncode != 0:
597
766
  r = run(["git", "push"])
598
767
  if r.returncode == 0:
599
- print("Git: 推送成功 ")
768
+ print_ok("push origin/main")
600
769
  else:
601
- print(f"Git push 失败: {r.stderr.strip()}", file=sys.stderr)
770
+ print_fail(f"push 失败: {r.stderr.strip()}")
602
771
  else:
603
- print("Git: 已跳过 push(--no-push 模式)")
772
+ print_info("已跳过 push(--no-push")
604
773
 
605
774
 
606
775
  # ─── 主入口 ───────────────────────────────────────────────────────────────────
@@ -645,51 +814,61 @@ def main():
645
814
  args = parser.parse_args()
646
815
 
647
816
  target_date = parse_date_arg(args.date)
648
- print(f"=== 收集 {target_date} 的工作日志 ===")
649
817
 
650
- # 1. 收集数据
651
- print("收集 Claude Code 对话记录...")
652
- claude_data = collect_claude_sessions(target_date)
653
- total_claude_turns = sum(
654
- sum(len(turns) for turns in sessions) for sessions in claude_data.values()
818
+ # 1. 检查 checkpoint
819
+ checkpoint = load_checkpoint(target_date)
820
+ is_incremental = checkpoint is not None
821
+ prev_file_counts = checkpoint["file_counts"] if is_incremental else {}
822
+ prev_time = checkpoint["generated_at"].split("T")[-1] if is_incremental else ""
823
+
824
+ print_header(target_date, is_incremental, prev_time)
825
+
826
+ # 2. 收集 Claude Code 对话
827
+ print_step("收集 Claude Code 对话记录")
828
+ claude_data, file_counts = collect_claude_sessions(
829
+ target_date, prev_file_counts if is_incremental else None
655
830
  )
656
- total_claude_sessions = sum(len(sessions) for sessions in claude_data.values())
657
- print(f" 找到 {len(claude_data)} 个项目,{total_claude_sessions} 个对话,{total_claude_turns} 轮交互")
831
+ print_project_tree(claude_data, is_sessions=True)
832
+ total_claude_turns = sum(sum(len(t) for t in s) for s in claude_data.values())
658
833
 
659
- print("收集 Codex 对话记录...")
834
+ # 3. 收集 Codex 对话
835
+ print_step("收集 Codex 对话记录")
660
836
  codex_data = collect_codex_sessions(target_date)
837
+ print_project_tree(codex_data, is_sessions=False)
661
838
  total_codex = sum(len(v) for v in codex_data.values())
662
- print(f" 找到 {len(codex_data)} 个项目,{total_codex} 条消息")
839
+
840
+ total_projects = len(set(list(claude_data.keys()) + list(codex_data.keys())))
841
+ total_turns = total_claude_turns + total_codex
663
842
 
664
843
  if args.dry_run:
665
- print("\n[dry-run] 数据预览:")
666
- for proj, sessions in claude_data.items():
667
- total_turns = sum(len(t) for t in sessions)
668
- print(f" [Claude/{proj}] {len(sessions)} 个对话,{total_turns} 轮")
669
- for si, turns in enumerate(sessions[:2], 1):
670
- print(f" 对话{si}: {len(turns)} 轮")
671
- for user_msg, asst_msg in turns[:2]:
672
- print(f" [用户] {user_msg[:60]}")
673
- if asst_msg:
674
- print(f" [Claude] {asst_msg[:60]}")
675
- for proj, msgs in codex_data.items():
676
- print(f" [Codex/{proj}] {len(msgs)} 条")
677
- for m in msgs[:2]:
678
- print(f" - {m[:60]}")
844
+ print(f"\n{dim('[dry-run] 预览完毕,未调用 API')}\n")
679
845
  return
680
846
 
681
- # 2. 生成摘要(通过 claude -p 复用 Claude Code CLI 认证)
682
- print("调用 Claude API 生成摘要...")
683
- content = generate_summary(target_date, claude_data, codex_data)
847
+ # 4. 生成日志
848
+ if is_incremental:
849
+ if total_claude_turns == 0 and total_codex == 0:
850
+ print(f"\n {yellow('─')} 没有新增内容,无需更新\n")
851
+ return
852
+ print_step(f"调用 {bold(MODEL)} 更新日志(增量)")
853
+ log_file_path = LOGS_DIR / target_date[:4] / f"{target_date}.md"
854
+ existing_log = log_file_path.read_text(encoding="utf-8") if log_file_path.exists() else ""
855
+ content = generate_incremental_update(target_date, existing_log, claude_data, codex_data)
856
+ else:
857
+ print_step(f"调用 {bold(MODEL)} 生成摘要")
858
+ content = generate_summary(target_date, claude_data, codex_data)
684
859
 
685
- # 4. 保存文件
860
+ # 5. 保存文件 + checkpoint
861
+ print_step("保存日志")
686
862
  log_file = save_log(target_date, content)
863
+ save_checkpoint(target_date, file_counts)
864
+ rel = str(log_file).replace(str(Path.home()), "~")
865
+ print_ok(rel)
687
866
 
688
- # 5. Git 操作
867
+ # 6. Git 操作
689
868
  if not args.no_git:
690
869
  git_commit_and_push(log_file, target_date, push=not args.no_push)
691
870
 
692
- print(f"\n完成!日志文件: {log_file}")
871
+ print_footer(log_file, total_projects, total_turns)
693
872
 
694
873
 
695
874
  if __name__ == "__main__":