- Macで1日の作業を記録したいと思って、まずスクリーンショットを撮り続けるツールを検討しました。
- 目的が「今日は何をしていたか」を振り返ることだと気づくと、画像は要らなくなります。
- ActivityWatchが記録するのはアプリ名とウィンドウタイトルだけですが、それだけでかなり思い出せます。
- 生ログは表示状態の違いで細かく分裂するので、正規化してからAIに渡します。
1. スクリーンショットは撮らなくてよかった
夕方になって、今日は何をやっていたんだろうと思うことがあります。 コードを書いていた時間より、調べものをしていた時間のほうが長かった気がする。 でも、その「気がする」を確かめる材料が手元にない。
Macで1日の作業を記録したいと思って、まずスクリーンショットを撮り続けるツールを検討しました。 画面を丸ごと残しておけば、あとから何でもわかるはずだと考えたわけです。 ところが、自分が欲しかったものを言葉にしてみると、そこまでは要らないことがわかってきました。
1.1. 画面を残すツールと、時間を数えるツール
候補として挙がったのは4つです。 Timing、ScreenMemory、screenpipe、ActivityWatch。 それぞれ記録するものが違います。
Timingは、アクティブなアプリについてウィンドウタイトルや開いている文書、閲覧中のWebサイトを自動的に記録します。 1秒単位でアクティブウィンドウを追跡し、操作のない時間は除外できる1。 1日のタイムラインを見れば、10時から10時35分はChrome、そのあと11時20分までVS Code、という形で追えます。 ただ、画面そのものを撮影して残すのは主目的ではありません。
そのとき実際に画面に何が映っていたかまで残したいならScreenMemoryです。 設定した間隔でスクリーンショットを撮り続け、あとから動画のようにスクラブして1日を見返せます2。 さらに一歩進めるとscreenpipeがあって、こちらはスクリーンショットに加えて、アクティブなアプリ、ウィンドウタイトル、ブラウザのURL、画面内の文字、必要なら音声までローカルに記録します。 OCR(Optical Character Recognition:光学文字認識)やmacOSのAccessibility APIから文字情報を取り出すので、昨日あの仕様を調べていたのは何時ごろだったか、といった検索までできる3。 その代わり、記録量もCPUとストレージの消費も跳ね上がります。
1.2. テキストだけで足りる
ここで条件を絞りました。 無料のオープンソースであること。 データサイズをそんなに食わないこと。 そして1日の最後に、そのデータをAIに読み込ませて、今日の作業内容をリストで出せること。
最後の条件が効きました。 欲しいのは細かな作業内容ではなく、どういうことをやっていたのかという粒度です。 「api.pyの42行目を編集していた」ではなく「音楽データの編集に約2時間」がわかればいい。 その解像度なら、画面の画像はむしろ邪魔になります。


残ったのがActivityWatchでした。 無料・オープンソースで、標準状態でも現在のアプリ、アクティブウィンドウのタイトル、操作していた時間と離席時間をローカルに記録します4。 スクリーンショット記録は標準機能に入っていない。 今回はそれが欠点ではなく、条件そのものでした。

2. ActivityWatchが記録しているもの
インストールして、localhost:5600 をブラウザで開くと管理画面が出ます。 アプリというより、Mac上で動いているローカルサーバーに接続する形です。 Timelineのタブを開くと、その日の記録が帯状に並んでいます。

上の段が aw-watcher-afk、下の段が aw-watcher-window です5。 afkはAway From Keyboardの略で、キーボードやマウスの操作があったかどうかだけを見ています。 緑の帯が not-afk、つまり操作していた時間。 その下に、実際にどのアプリがアクティブだったかが細かく刻まれています。
帯にカーソルを合わせると、そのイベントの中身が出ます。 Start 11:45:52、Stop 11:46:07、Duration 15s、App Google Chrome、Title「ActivityWatch – Open-source time tracker – Google Chrome」6。 15秒。 4時間分を表示したら、イベント数は96個になっていました。
記録されているのは、この4つだけです。 アプリ名、ウィンドウタイトル、開始時刻、継続時間。 macOSではアクセシビリティ機能へのアクセスを許可しないとウィンドウタイトルが取れないので、初回に許可を求められます7。
2.1. ウィンドウタイトルだけで、かなりわかる
ActivityWatchにはブラウザ拡張の aw-watcher-web もあって、これを入れるとURL単位で記録できます8。 最初は要らないと思いました。 ブラウザのウィンドウタイトルには、たいていページのタイトルがそのまま入っているからです。
「ActivityWatch – Open-source time tracker – Google Chrome」というタイトルを見れば、ActivityWatchについて調べていたことはわかります。 GitHub、Google Docs、Claude、YouTube。 このあたりはタイトルだけで十分に判別がつく。
どのドメインのどのページを何秒見ていたか、という粒度が必要になったときに拡張を足せばいい。 先に全部入れてしまうと、後から「これは要らなかった」と判断する材料がなくなります。
3. 取り出すところで二か所つまずいた
集計はPythonで書きました。 ActivityWatchのローカルREST APIから「Canonical Events」を取ります。 REST APIというのは、他のプログラムからデータを取り出すための窓口のことです。 Canonical Eventsのほうは、ウィンドウ記録とAFK検出を突き合わせて、離席時間を除いた状態にまとめたデータを指します9。
ここで2回つまずきました。
1回目は、クエリを投げるとHTTP 400が返ってくる。 原因は、サーバー側のカテゴリ設定に入っている Uncategorized でした。 この項目のルールが type: null になっていて、それがそのままクエリに混ざると、ActivityWatchが受け付けません。 今回はカテゴリ分類を使わないので、有効な「ルールなし」のカテゴリを明示して回避しました。
AWLOG_CLASSES = [(["Uncategorized"], {"type": "none"})]
Code language: Python (python)
2回目は、しばらく動いていたのに突然400が返るようになった。 スリープから復帰して、ネットワークが切り替わったあとです。 ホスト名が MacBook-Air-2 から MacBook-Air-2.local に変わっていて、 そこから組み立てたバケット名が存在しないものになっていました。
最初のコードは、接続時に取れるホスト名から aw-watcher-window_ホスト名 を作っていました。 これをやめて、実際に存在するバケットの一覧を取り、windowとAFKの組が揃っているホストを選ぶ方式に変えます。 末尾の .local は削って同一視する。 同じホストのバケットが複数あるときは、最後に更新されたほうを取ります。
推測で名前を組み立てるより、あるものから選ぶほうが壊れにくい。 当たり前ではあるのですが、動いているうちは気づきませんでした。
4. 足切りが半分を捨てていた
動くようになって出力を眺めていて、数字が合わないことに気づきます。
その日の活動時間は7時間53分。 ところが、明細に出ている時間を足すと4時間9分にしかならない。 3時間44分が消えています。
原因は自分で入れた足切りでした。 同じ時間帯・同じアプリ・同じタイトルで合算して300秒未満のものを捨て、各時間帯の上位4件だけを表示していた。 細切れの作業がそのまま落ちていたわけです。
日誌に貼る前提なら、それでよかったかもしれません。 でもAIに渡して畳ませるなら、捨てるのは向こうの仕事です。 既定の足切りを0秒にして、上位4件の制限も外しました。
あわせて表示も直します。 1分未満をすべて 0m と出していたのを、45s や <1s のように書き分ける。 0m が何十行も並ぶと、そこに何かがあったこと自体が見えなくなります。
5. 表示状態を削って、作業名にまとめる
全件を出すと、今度は別の問題が出てきました。
WezTerm [1/3] ⠧ chatgpt-meum
WezTerm [2/3] ⠦ retmed
WezTerm [1/3] [ ! ] Action Required | retmed
WezTerm [3/3] ⠹ nesprile
ターミナルのタイトルには、ペイン番号、進捗を表す点字のスピナー、コマンドラインツールの状態表示が入ります。 これが1文字変わるたびに別のタイトルとして数えられる。 ある1時間の出力が、これだけで200行近くになっていました。
やっているのは3つのプロジェクトです。 chatgpt-meum、retmed、nesprile。 表示状態を削れば、200行が3行になります。
削ってよいものと、残すべきものの線引きが要ります。 削るのは、変わっても作業内容の意味が変わらない表示。 未読件数、受信件数、ブラウザ名、再生中かどうか、ペイン番号、スピナー、保存済みかどうか、画像の色深度やサイズ。 残すのは、プロジェクト名、ファイル名、検索語、ページ名、フォルダ名、操作の名前。
迷ったら残す。 合算して行数が減っても、AIが作業内容を推測する手掛かりが消えたら意味がありません。
5.1. ルールをJSONに出して、テストを添える
最初は正規表現をPythonのソースに直接書いていました。 ところがアプリを1つ足すたびにルールが増え、前に書いた置換を壊す事故が起きます。 GIMPのタイトルを整えたら、Emacsの表示が変わってしまう、というような。
そこでルールを別ファイルに出しました。
{
"name": "wezterm-pane-number",
"app_pattern": "^WezTerm$",
"title_pattern": "^\\[\\d+/\\d+\\]\\s*",
"replacement": ""
}
Code language: JSON / JSON with Comments (json)
対象アプリの正規表現、タイトルの正規表現、置換文字列。 上から順に適用して、各置換のあとに前後の空白を落とします。 全部適用してタイトルが空になったら、アプリ名をタイトルとして使う。
同じJSONに、入力例と期待値も置きます。
{
"app": "WezTerm",
"title": "[3/3] [ . ] Action Required | nesprile",
"expected": "nesprile"
}
Code language: JSON / JSON with Comments (json)
これで、ルールを足したあとに確認できます。
python3 ~/.local/bin/aw-daily.py --check-rules
Code language: Bash (bash)
ルール10件から始めて、今のところ壊れていません。 新しいアプリを使い始めたら、その日の出力を眺めて、表示状態だけが違うタイトルを拾う。 ルールを足して、検証例も足す。 この2つをセットにしておかないと、あとで直せなくなります。
6. 日誌に貼れる形まで束ねる
集計が整ったので、AIに渡してみました。
最初は5項目の要約を出させていたのですが、自分が書いている業務日誌とは形が違うことに気づきます。 日誌のほうは、時刻があって、固有名詞がそのまま残っている。
・9時 A社への請求書を作成。金額を先方の依頼分と照合
・10時 見積の件で先方に連絡。回答待ち
・13時 講習資料作成
「開発作業:約3時間」のような抽象化された行とは、用途が違います。 5項目の要約は1日の傾向を見るには合っていますが、日誌には貼れません。
ログから書けるのは、このうちパソコンで触った分だけです。 電話で何を話したかも、その場で決めたことも、記録には出てきません。 埋まらない行は手で足す前提で、埋まる行だけをログから作る。 そう割り切ったほうが実用的でした。
6.1. 作業単位で束ねる
粒度は時間単位で足ります。 9時台にどのファイルを開いていたかがわかれば、あとは思い出せる。
ただし、時間帯ごとに1行ずつ並べると、間延びします。 同じ作業が17時、18時、19時と細切れに続いた日は、それだけで3行になる。 そこで、同じ成果物に向かっていた操作は時間帯をまたいで1つに束ね、 最も長かった時間帯の行に、合計時間と「どこまで続いたか」を添えることにしました。
・10時 音楽データを編集(計約1時間55分、20時台まで断続)
束ねる基準はアプリではありません。 同じアプリでも成果物が違えば別の作業です。 AIを13時に記事執筆、15時にスクリプト作成で使っていたなら、これは2行に分ける。 逆に、GUIアプリの操作とターミナルでの作業が同じプロジェクトなら、1行にまとめる。
この方針にしたら、13行あった下書きが8行になりました。
6.2. ファイル名が日誌の材料になる
この形式にしてから、ファイルの名前の付け方が変わりました。
「見積書.xlsx」だと、後から見て何の分かわかりません。 「2026-07_請求書.numbers」なら、その1行だけで日誌になる。 タイトルバーに出た文字列がそのまま材料になるので、名前を付ける時点で日誌を半分書いていることになります。
メールやメッセージアプリは本文が残らないので、ここだけは毎回手で足す行として残ります。 それでいいと思っています。 相手と用件は覚えているもので、忘れるのは「そのあと何をしたか」のほうだからです。
ウィンドウタイトルには、取引先の名前や案件名がそのまま出ます。 記録としてはそれが役に立つ一方、ファイルをどこかへ渡すときは扱いに気をつける必要がある。 集約スクリプトの側に除外語のリストを持たせて、特定の語を含む行を最初から落とすようにしています。
7. 呼び出し用のスクリプト
集計本体は ~/.local/bin/aw-daily.py に置き、日付の解釈はシェルスクリプトに任せました。
awlog 今日
awlog yesterday 昨日
awlog week 今週の月曜から今日まで
awlog lastweek 先週の月曜から日曜
awlog 2026-08-01 2026-08-07 期間指定
範囲を指定すると1日ずつ処理して、日ごとに出力します。 -o にディレクトリを渡せば日付名のファイルに保存し、-m で足切りの秒数を指定できる。 既定は0で全件、日誌用に絞りたいときだけ -m 300 を付けます。
日付の計算は date -v-mon のようなBSD版の書き方に頼っています。 Homebrewのcoreutilsを入れていて gdate が優先されている環境では、このオプションが通りません。 -j と -v と -f の順序でも一度つまずきました。 2日目の日付がISO形式にならず、ループが止まらなくなる。
自分の1日を振り返るのに、画面を全部撮る必要はありませんでした。 どのウィンドウを見ていたかというテキストが数百行あれば、あとはそれを人間が読める粒度に畳むだけ。 畳み方を決めるのに丸一日かかりましたが、それも記録に残っています。
- Timingの公式FAQでは、1秒に1回、アクティブなウィンドウのタイトルと文書パスまたはURLを記録し、バックグラウンドのアプリは記録しないと説明されています。マウスとキーボードの操作がない状態が設定した時間続くと、追跡は自動的に中断されます。 – Frequently Asked Questions – Timing
- ScreenMemoryは撮影と同時にローカルで軽量なOCR処理を行い、画面に映った文字を検索できるようにしています。パスワードマネージャーなどのアプリを撮影対象から除外する設定もあります。 – ScreenMemory – Photographic Memory for your Mac
- screenpipeはアプリの切り替えやクリック、入力の停止といったOSのイベントを検知したタイミングで、スクリーンショットとアクセシビリティツリーを同じ時刻で取得します。アクセシビリティ情報が取れない場合にOCRへ切り替える設計です。データはローカルのSQLiteに保存され、スクリーンショットはJPEGで8時間あたり約300MBとされています。 – screenpipe/screenpipe – GitHub
- 公式FAQでは、クラウド型の時間記録サービスと違い、すべてのデータが端末内に保存されると説明されています。Windows、macOS、Linux、Androidで動作します。 – FAQ – ActivityWatch Documentation
- ActivityWatchは「ウォッチャー」と呼ばれる小さなプログラムを組み合わせる構成で、標準ではウィンドウ用とAFK用の2つが動きます。エディタやブラウザ用のウォッチャーは後から追加できます。 – Watchers – ActivityWatch Documentation
- ActivityWatchのイベントは、タイムスタンプ、継続時間、任意のキーと値の組を持つdataという3つのフィールドで構成されます。時刻はすべてUTCで保存されます。 – Data model – ActivityWatch Documentation
- aw-watcher-windowのREADMEには、ウィンドウタイトルを記録するにはmacOSのアクセシビリティAPIへのアクセスが必要だと明記されています。許可がない場合は、アプリ名までしか記録できません。 – ActivityWatch/aw-watcher-window – GitHub
- この拡張はアクティブなタブのタイトル、URL、音声を再生しているかどうか、シークレットウィンドウかどうかを記録します。拡張がない場合、ActivityWatchはデスクトップ側のウォッチャーが取得するブラウザのウィンドウタイトルしか見られません。 – ActivityWatch/aw-watcher-web – GitHub
- Canonical Eventsは、複数のソースの生イベントを統合して意味のある単位にまとめたものです。ActivityWatchのWeb UIが表示に使っているのも、このデータです。 – Working with ActivityWatch Data