Show original
Enjoyed this article?
Use "Request tipping" to ask the author to set up tip receiving.

AI translation
― 毎晩自動で蒸留するパイプライン
Enjoyed this article?
Use "Request tipping" to ask the author to set up tip receiving.
This is the "Claude Code Environment" series. In 記憶を4層に分けた話 I wrote "conversation logs = layer 1" and "Obsidian Vault = layer 4," but this time I'm writing the implementation of the automated distillation pipeline that runs every night from layer 1 to layer 4.
Raw conversation logs are too massive to read. Meanwhile, the human Wiki in Obsidian is structured long-term memory. I'm running this unattended: every night, appending only the most recent 28 hours while preserving domain structure. What I've learned from doing this—how to eliminate silent failures and mechanisms to prevent fabrication—is the substance of this article.
Launched from launchd every morning, there are roughly 3 stages:
Distillation is split into 2 separate runs by source (Claude conversations and Codex conversations). The reason, explained later, is that combining them into one exceeded the time window and timed out every night.
Everything got stuck on this first. The Vault is under ~/Documents, which is protected by macOS's TCC (privacy protection). Even if you run git from launchd, it fails because it can't write to the protected area.
What makes it worse is that this tends to become a "silent failure." So I detect it early with a preflight check and log loudly + send notifications.
# launchd配下では ~/Documents(保護領域) に触れない場合がある。
# ここで早期検知し、サイレント失敗(exit 0偽装)を防ぐ。
if ! ( cd "$VAULT" && git rev-parse --git-dir >/dev/null 2>&1 ); then
echo "❌ FDA未付与: launchdから '$VAULT' にアクセス不可(TCC保護)" >> "$LOG"
notify_fail "FDA未付与(設定→フルディスクアクセス→/bin/bash を許可)"
exit 1
fiThe solution is "System Settings → Privacy and Security → Full Disk Access, permit /bin/bash". Place the script itself outside the protected area (~/.claude/scripts/). If you put it in ~/Documents, launchd can't exec it.
I also tried a symlink escape to ~/, but iCloud's conflict handling breaks symlinks, so that's not viable. When protected areas and iCloud sync overlap, constraints that defy intuition multiply. It's stable to be strict about placement: scripts outside protection, data inside protection.
When you close the lid and sleep, nighttime jobs freeze. caffeinate -s only works on AC power, so on battery it just stops normally. So I made it self-healing: retry across multiple slots until success, then exit immediately.
DONE_MARKER="$HOME/.claude/logs/.vault-ingest-done-${TODAY}"
[ -f "$DONE_MARKER" ] && exit 0 # 本日成功済みなら即終了Splitting distillation into 2 runs (Claude/Codex) is for the same reason. On busy days, digesting 28 hours of logs + article rewrites exceeded the 40-minute window and timed out daily, which froze the hot cache. Each substep has its own independent marker, so if one times out, the next slot retries only what remains.
This is the most important lesson. When you throw "write today's brief from the conversation log" at claude -p, it sometimes invents plausible constraints and durations for events not in the notes. It sees prose like "M/D〜M/D" and inflates it into "this period is entirely constrained."
There are two countermeasures.
① Make the calendar's actual snapshot the canon. Instead of prose, pull scheduled events with timestamps from Google Calendar API, drop them in a separate file, and instruct: "treat this as the sole canonical schedule."
② If retrieval fails, preserve last-known-good. Even if the API fails, don't wipe the canon empty. Keep the last successful value with a stale marker.
if [ -n "$CAL_SRC" ]; then
cp "$CAL_TMP" "$CAL_SNAPSHOT"; cp "$CAL_SNAPSHOT" "$CAL_LASTGOOD"
elif [ -s "$CAL_LASTGOOD" ]; then
# 取得失敗: 前回goodを温存し ⚠️stale 印で再構築(last-known-good)
{ echo "⚠️ 本日取得失敗。以下は前回成功時点の値(stale)。"; tail -n +4 "$CAL_LASTGOOD"; } > "$CAL_SNAPSHOT"
fiAnd constrain it hard on the prompt side too. "Don't invent information not in the notes." "Explicitly separate confirmed facts from speculation in date/time claims." If you're having AI write long-term memory, you can only kill the room for fabrication through structure.
When archiving briefs with dates, only files newer than this run's start time count as today's output.
START_STAMP=$(mktemp ...) # ラン開始時刻スタンプ
# ...生成...
if [ -s "$BRIEF_SRC" ] && [ "$BRIEF_SRC" -nt "$START_STAMP" ]; then
cp "$BRIEF_SRC" "$ARCH_FILE"; touch "$DONE_MARKER" # 新しい時だけ完了扱い
else
notify_fail "ブリーフ未完 — 次スロットで自動再試行" # 古いまま=失敗、再試行へ
fiWithout this, even if generation times out, you'd copy "yesterday's old brief" and record it as "success." By judging freshness via mtime, you prevent failure from masquerading as success.
Next time, I'll write about the parent orchestrator of this unattended job ―― Claude Codeの自律ループを24時間回し、コストで暴走を止める.
Lily (@bokuwalily) ― Individual developer. Building automation infrastructure with Claude Code while mass-producing iOS apps and web services
◼︎ Apps I've made are summarized at **ポートフォリオ**📱
◼︎ New releases and development behind-the-scenes on X **@bokuwalily**🐦
◼︎ OSS: **github.com/bokuwalily**🐙
Your ❤️ and shares are my motivation!
「Claude Code環境」シリーズです。記憶を4層に分けた話で「会話ログ=層1」「Obsidian Vault=層4」と書きましたが、今回はその層1から層4へ毎晩自動で蒸留する配管の実装を書きます。
会話ログは生のままだと巨大で読めません。一方Obsidianの人間Wikiは構造化された長期記憶です。この2つを「毎晩、直近28時間分だけ、ドメイン構造を守って追記する」のを無人で回しています。やってみて分かったサイレント失敗の潰し方と捏造させない仕掛けが、この記事の中身です。
launchdから毎朝起動して、おおまかに3工程です。
蒸留はソース別に2本(Claude会話とCodex会話)に分けています。理由は後述しますが、1本にまとめると時間枠に収まらず毎晩timeoutしていたからです。
最初に全部詰まったのがこれです。VaultはiCloud同期の ~/Documents 配下にあり、macOSのTCC(プライバシー保護)で守られています。launchdから git を走らせても、保護領域に書けずに失敗する。
しかも厄介なのは、これが「サイレント失敗」になりやすいこと。だからプリフライトで早期検知して大声でログ+通知します。
# launchd配下では ~/Documents(保護領域) に触れない場合がある。
# ここで早期検知し、サイレント失敗(exit 0偽装)を防ぐ。
if ! ( cd "$VAULT" && git rev-parse --git-dir >/dev/null 2>&1 ); then
echo "❌ FDA未付与: launchdから '$VAULT' にアクセス不可(TCC保護)" >> "$LOG"
notify_fail "FDA未付与(設定→フルディスクアクセス→/bin/bash を許可)"
exit 1
fi解決は「システム設定 → プライバシーとセキュリティ → フルディスクアクセス で /bin/bash を許可」。スクリプト自身は保護外(~/.claude/scripts/)に置きます。~/Documents に置くとlaunchdからexecできません。
symlinkで ~/ に逃がす案も試しましたが、iCloudがsymlinkを競合処理して壊すため不可でした。保護領域とiCloud同期が重なると、直感に反する制約が増えます。スクリプトは保護外・データは保護内、と置き場所を割り切るのが安定します。
蓋を閉じてスリープすると、夜間ジョブは凍結します。caffeinate -s はAC電源時しか効かないので、バッテリー駆動だと普通に止まる。そこで**「成功するまで複数スロットで再試行、成功したら即終了」**の自己回復型にしました。
DONE_MARKER="$HOME/.claude/logs/.vault-ingest-done-${TODAY}"
[ -f "$DONE_MARKER" ] && exit 0 # 本日成功済みなら即終了蒸留を2本(Claude/Codex)に割ったのも同じ理由です。活動の多い日は28時間分のログ消化+記事リライトが40分枠に収まらず連日timeoutし、それが原因でホットキャッシュが凍結していました。サブステップごとに独立マーカーを持たせ、片方がtimeoutしても次スロットが残りだけ再試行します。
これがいちばん大事な学びです。claude -p に「会話ログから今日のブリーフを書け」と投げると、ノートに無い予定の拘束時間や所要日数を、それらしく創作することがありました。「M/D〜M/D」という散文を見て「この期間ずっと拘束される」と膨らませてしまう。
対策は2つです。
① カレンダーの実体スナップショットを正典にする。 散文ではなく、Google Calendar APIから取った時刻付きの予定を ground truth として別ファイルに落とし、「これを唯一の正典スケジュールとして読め」と指示します。
② 取得失敗時はlast-known-goodを温存する。 APIが失敗しても、正典を空で潰さない。前回成功時の値を stale 印付きで残します。
if [ -n "$CAL_SRC" ]; then
cp "$CAL_TMP" "$CAL_SNAPSHOT"; cp "$CAL_SNAPSHOT" "$CAL_LASTGOOD"
elif [ -s "$CAL_LASTGOOD" ]; then
# 取得失敗: 前回goodを温存し ⚠️stale 印で再構築(last-known-good)
{ echo "⚠️ 本日取得失敗。以下は前回成功時点の値(stale)。"; tail -n +4 "$CAL_LASTGOOD"; } > "$CAL_SNAPSHOT"
fiそしてプロンプト側でも強く縛ります。「ノートに無い情報を推測で足すな」「日付・時間の主張は確定情報と推測を明示的に分けろ」。AIに長期記憶を書かせるなら、創作の余地を構造で潰すしかありません。
ブリーフを日付つきでアーカイブするとき、「このランの開始時刻より新しいファイルだけ」を今日の成果と認めます。
START_STAMP=$(mktemp ...) # ラン開始時刻スタンプ
# ...生成...
if [ -s "$BRIEF_SRC" ] && [ "$BRIEF_SRC" -nt "$START_STAMP" ]; then
cp "$BRIEF_SRC" "$ARCH_FILE"; touch "$DONE_MARKER" # 新しい時だけ完了扱い
else
notify_fail "ブリーフ未完 — 次スロットで自動再試行" # 古いまま=失敗、再試行へ
fiこれが無いと、生成がtimeoutしても「前日の古いブリーフ」をコピーして「成功」と記録してしまう。鮮度をmtimeで判定することで、失敗を成功に偽装しないようにしています。
次回は、この無人ジョブの親玉 ―― Claude Codeの自律ループを24時間回し、コストで暴走を止めるを書きます。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
◼︎作ったアプリは **ポートフォリオ** にまとめています📱
◼︎新着・開発の裏側は X **@bokuwalily** で発信しています🐦
◼︎OSS: **github.com/bokuwalily**🐙
皆さんの ❤️ やシェアが励みになります!