1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

朝、布団の中でスマホを見ていると、PCのスピーカーから結月ゆかりの声が飛んでくる。

「マスター、それ二度寝のフラグですよ。記録、更新中です」

返事をしただけでは止まらない。目を開けただけでも止まらない。布団から物理的に出るまで、彼女は粘る。出たら出たで、今日の予定と天気と、ついでに「昨日より15分早いですね」と添えてくる。

これは目覚ましアプリではなく、Claude Codeで動いている生活アシスタントです。15分ごとにPCが自分で起きて、カメラで部屋を見て、必要なときだけ結月ゆかりとして喋ります。夜は逆に、起きていれば短く促し、寝ていれば黙って記録だけ残す。週末にはその一週間がHTMLレポートになる。

キャラクタに開設させた動画が、YouTubeに置いてあります。この動画から来てくださった方もいるかもしれません。

  • YouTube動画: https://youtu.be/vi62fRLMJ8s

この記事は、動画を見て「自分のPCにも住まわせたい」と思った人向けの技術記事です。Claude Code、A.I.VOICE、PTZカメラ、Discord、SQLiteをどう組み合わせると、こういう同居人になるのかを書きます。

動画が役に立ったら、YouTube側でチャンネル登録してもらえると嬉しいです。

AI生活アシスタントの全体構成

何ができるのか

時間で鳴るだけの目覚ましではありません。PCが15分ごとに自分の状態を確認し、必要なときだけ結月ゆかりとして行動します。今は以下をさせています。

できること 何を見るか 何をするか
朝の起床支援 時刻、カメラ、予定、天気 布団から出るまで声をかけ、起きたら予定と天気を伝える
夜の就寝チェック 時刻、カメラ、翌日の予定 寝ていれば黙って記録、起きていれば短く促す
部屋状態の記録 カメラ 寝ている/デスク/食事/不在などを15分単位で記録
Discord連携 未読メッセージ 依頼に返信、必要な報告をテキストで送る
週次レポート 生活ログ、天気、体重 一週間の傾向をHTMLとグラフでまとめる
機能追加 手順書と道具説明 服薬確認、作業時間レビューなどを後から足せる

肝は「AIに生活を丸投げする」ことではありません。AIが判断する材料と、守るべき手順を、毎回こちらから渡すことです。丸投げした瞬間に、生活アシスタントはただの占い師になります。

なぜClaude Codeなのか

実行役にClaude Codeを選んだ理由は単純で、もともと別でサブスクを契約していたからです。なんでもできるので、生活アシスタントに必要な操作もほとんど全部可能です。

  • ローカルファイルを読める
  • Markdownの手順書をそのまま渡せる
  • PythonやPowerShellを呼び出せる
  • カメラで撮った静止画を見て判断できる
  • SQLiteへ書ける
  • TTSやDiscordの小さなCLIを叩ける

周辺の道具はこんな構成です。

道具 用途
Windowsタスクスケジューラ 15分ごとの起動
Python 定期実行、DB、外部API呼び出し
SQLite 生活ログ
A.I.VOICE / tts_bridge 結月ゆかりの発話
PTZカメラ(Tapo C220) 部屋の状態確認
OpenWeatherMap 天気
カレンダーAPI 予定とルーティン
Discord Bot 依頼受付と報告

結月ゆかりという「同居人」を設計する

このシステムでキャラクター性は飾りではありません。

朝に起こされる、夜に寝るよう促される、生活レポートで癖を指摘される。同じ内容でも「誰の声で届くか」で続きやすさが全然違います。無味乾燥な通知アプリは三日で飽きる。でも結月ゆかりに「記録、更新中ですよ」と煽られると、なぜか体が起き上がる。

設定はこんな感じです。

  • 名前は結月ゆかり
  • A.I.VOICEの「結月ゆかり」声で話す
  • 丁寧なですます調、ただし堅すぎない
  • ユーザーを「マスター」と呼ぶ
  • 朝の声かけ、夜の促し、予定、天気、ゴミ、カレンダーなど日々の段取りを見る
  • 過去に効いた声かけや失敗を蓄積する
  • その他いろいろ

同時に、マスター側の情報も育てていきます。呼び方、好き嫌い、嫌がる言い方、効いた起こし方、夜に刺さる促し方、生活ルーティン、Discordで出た期待や地雷。これがないと結月ゆかりは毎回「一般的に正しいこと」を言うだけのbotになります。

割とざっくりした設定でも、朝は寝起きでも処理できるように短く具体的に。夜に何度も起きていたら少し心配そうに。予定がある日は起きる理由を添える。そういうことを設定なしでこなしてくれます。

タスクの判断基準は人格から切り離します。

  • 返事をしただけでは起床完了にしない
  • 布団内でスマホを見ていても起床完了にしない
  • 寝ていると判断したら話しかけない
  • 不在判定は複数方向を見てから行う

これらは結月ゆかりの気分ではなく、タスク手順として書きます。人格は「どう話すか」、手順は「何を根拠に判断するか」。ここを混同しないようにすると、システムがぶれません。

全体構成

ざっくりこういう流れで、15分に一回動いています。

15分ごとの巡回実行
  ├ 今回やる仕事を決める
  ├ カメラ・予定・天気・Discord未読・過去ログを読む
  ├ Claude Codeに今回の文脈と手順書を渡す
  ├ 必要ならTTSで発話、Discordへテキスト投稿
  ├ SQLiteへ結果を記録
  └ 週次レポートで集計

15分ごとの巡回実行

実装上は、巨大なプロンプト1枚に全部書かない方が安定します。役割を分けます。

役割 内容
起動入口 OSから15分ごとに呼ばれる小さな処理
仕事の台帳 起床、就寝、日次、週次などの発動時間と完了条件
タスク手順書 起床支援や就寝チェックの具体的な手順
道具説明書 カメラ、天気、Discord、体重同期などの呼び出し方
生活ログDB 状態、起床、就寝、実行結果、レポート履歴
レポート生成 SQLiteからMarkdown/HTML/PNGを作る処理

15分ごとに起きるPC(ここが核)

前章の役割分担で言うと「起動入口」と「仕事の台帳」にあたる部分です。ここが骨格で、朝の起こし方や夜の促し方、カメラの動かし方といった個別の中身は全部この上に乗ります。

骨格さえ動けば、個別の中身はMarkdownの手順書を書き換えるだけで自由に変えられます。逆にここが雑だと、いくら個別を作り込んでも全体は安定しません。なのでこの章は実コードで書きます。

全体の動き

PCは15分に1回だけ起きて、tick.py という小さなPythonスクリプトを実行します。これがやるのは、こういう流れ。

[Windowsタスクスケジューラ] → tick.py
    ├ 排他ロックを取る(前回が走行中ならスキップ記録して即終了)
    ├ 現在時刻を15分グリッドに正規化(08:37 → 08:30)
    ├ 「今この時刻で発動すべきタスク」を計算
    ├ Discordの未読を取得
    ├ プロンプト本体 + 各タスクの手順書本文を結合
    ├ claude --print に標準入力で渡す(タイムアウト3時間)
    ├ Claude Codeが内部でカメラ撮影・タスク実行・DB書き込み
    └ tick_logにok/error/timeout/skippedで記録

ポイントは3つ。

  1. タスクを発動するか/しないかはPython側で決める。AIに時刻判定させない
  2. 手順書は本文をプロンプトに直接埋め込む。AIに「必要なら読んで」と任せない
  3. 多重起動はファイルロックで弾く。起床タスクは数時間かかる可能性があるので必須

順に見ていきます。

スケジューラ登録

Windowsタスクスケジューラで XX:07 / XX:22 / XX:37 / XX:52 に起動します。切りのいい時刻(00/15/30/45)を少し避けているのは、他の定時処理と衝突させないため。

PowerShellで一発登録します。

$ProjectDir = "C:\path\to\life_assistant"
$ScriptsDir = Join-Path $ProjectDir "scripts"
$UvExe = "uv"

$action = New-ScheduledTaskAction `
  -Execute $UvExe `
  -Argument "run --directory `"$ScriptsDir`" python tick.py" `
  -WorkingDirectory $ScriptsDir

$triggers = @()
foreach ($mm in 7, 22, 37, 52) {
  $t = New-ScheduledTaskTrigger -Daily -At "00:$mm"
  $t.Repetition = (New-ScheduledTaskTrigger `
    -Once -At "00:$mm" `
    -RepetitionInterval (New-TimeSpan -Hours 1)).Repetition
  $triggers += $t
}

$settings = New-ScheduledTaskSettingsSet `
  -AllowStartIfOnBatteries `
  -DontStopIfGoingOnBatteries `
  -WakeToRun `
  -StartWhenAvailable `
  -ExecutionTimeLimit (New-TimeSpan -Hours 4)

Register-ScheduledTask -TaskName "YukariTick15min" `
  -Action $action -Trigger $triggers -Settings $settings -Force

WakeToRun を入れておくと、PCがスリープ中でも起こして実行してくれます。ExecutionTimeLimit は、起床支援が長引いた時に途中で殺されないよう余裕を持たせます。

tick.py:起動入口

ここがシステムの心臓部です。このファイル1つで全てが動きます。

# scripts/tick.py
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import json, os, shutil, sqlite3, subprocess, sys
import portalocker

JST = ZoneInfo("Asia/Tokyo")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = PROJECT_ROOT / "data" / "life.db"
LOCK_PATH = PROJECT_ROOT / "data" / "tick.lock"
PROMPT_PATH = Path(__file__).resolve().parent / "tick_prompt.md"

CLAUDE_TIMEOUT_SEC = 3 * 60 * 60  # 3時間
STALE_LOCK_HOURS = 6              # これより古いロックは強制解放


def normalize_tick_at(now: datetime) -> datetime:
    """08:37:20 → 08:30、08:52:05 → 08:45"""
    minute = (now.minute // 15) * 15
    return now.replace(minute=minute, second=0, microsecond=0)


def run_tick() -> int:
    started_at = datetime.now(JST)
    tick_at = normalize_tick_at(started_at)

    # クラッシュで残った古いロックを掃除
    if LOCK_PATH.exists():
        age = datetime.now() - datetime.fromtimestamp(LOCK_PATH.stat().st_mtime)
        if age > timedelta(hours=STALE_LOCK_HOURS):
            LOCK_PATH.unlink(missing_ok=True)

    LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
    lock = open(LOCK_PATH, "a+")
    try:
        try:
            portalocker.lock(lock, portalocker.LOCK_EX | portalocker.LOCK_NB)
        except portalocker.exceptions.LockException:
            record_skipped(tick_at, started_at)
            return 0

        # ロック取得成功 → デバッグ用にPIDを書き残す
        lock.seek(0); lock.truncate()
        lock.write(f"{os.getpid()} {started_at.isoformat()}\n")
        lock.flush()

        record_start(tick_at, started_at)

        # タスク列を組み立てる(後述)
        invocations = build_invocations(tick_at)

        # プロンプト = 共通手順書 + コンテキスト(手順書本文をinline展開)
        prompt = PROMPT_PATH.read_text(encoding="utf-8") \
               + build_context(tick_at, invocations)

        rc, stdout, stderr = invoke_claude(prompt)
        record_finish(tick_at, started_at, rc, stderr,
                      [inv.name for inv in invocations])
        return 0 if rc == 0 else 1
    finally:
        try: portalocker.unlock(lock)
        except Exception: pass
        lock.close()

ロックを取れなかった時にスキップ記録だけ残して即終了するのが地味に大事です。記録がないと「なぜ前回動かなかったのか」が分からなくなる。

Claude Code を呼ぶ

claude --print で非対話実行します。プロンプトを渡す時、argv ではなく標準入力で渡すのが重要です。

def invoke_claude(prompt: str) -> tuple[int, str, str]:
    claude_bin = shutil.which("claude") or "claude"
    cmd = [
        claude_bin,
        "--print",                          # 非対話モード
        "--dangerously-skip-permissions",   # 実運用ではこれで無人実行している
        "--model", "claude-sonnet-4-6",
        "--input-format", "text",
        "--output-format", "text",
    ]
    try:
        proc = subprocess.run(
            cmd,
            input=prompt,                   # ← stdin で渡す
            capture_output=True,
            text=True, encoding="utf-8", errors="replace",
            timeout=CLAUDE_TIMEOUT_SEC,
            cwd=str(PROJECT_ROOT),
        )
        return proc.returncode, proc.stdout, proc.stderr
    except subprocess.TimeoutExpired as e:
        return -1, e.stdout or "", (e.stderr or "") + "\n[timeout]"

なぜstdinかというと、WindowsのCMD引数長上限(約8KB)でプロンプトが切り詰められるからです。手順書をinline展開すると軽く数十KBになるので、argv渡しだと結月ゆかりに肝心の指示が届かず、「なんか今日のあの子、挙動おかしいな」になります。一度ハマってからずっとstdin渡しです。

この実装では無人実行のため --dangerously-skip-permissions を使っています。ただ、今から組むなら --permission-mode auto も先に試すと思います。Auto modeは危険そうなツール呼び出しをブロックしてくれるので、claude --print で定期実行する用途と相性がよさそうです。

Auto modeでブロックが続くと、非対話実行ではその回がabortされます。なので、どちらの方式でも tick_logerror / timeout を残し、次の巡回で再試行できる形にしておくのが大事です。

仕事の台帳:宣言的にタスクを定義

「いつ・何を・どういう条件で発動するか」を、コードの中に宣言として持ちます。

# scripts/task_registry.py
from dataclasses import dataclass

@dataclass(frozen=True)
class TaskDef:
    name: str
    doc_path: str                        # 手順書のパス
    default_window: tuple[str, str]      # ('HH:MM','HH:MM') 半開区間
    once_per_day_table: str | None       # 冪等性判定に使うテーブル
    done_filter: str | None = None       # 「完了」とみなす追加WHERE条件
    cadence: str = "daily"               # 'daily' | 'weekly:sat' など


TASK_REGISTRY: dict[str, TaskDef] = {
    "wake-up": TaskDef(
        name="wake-up",
        doc_path="docs/tasks/wake-up.md",
        default_window=("07:30", "08:30"),
        once_per_day_table="wake_log",
    ),
    "sleep-check": TaskDef(
        name="sleep-check",
        doc_path="docs/tasks/sleep-check.md",
        default_window=("22:30", "00:00"),
        once_per_day_table="sleep_log",
        # 「寝るまで15分ごとに声かけ続ける」を実現するためのキモ。
        # outcome=gave_up は「今tickでは寝なかった」記録。完了とはみなさず、
        # ウィンドウ内なら次tickで再発動させる。
        done_filter="outcome IN ('went_to_bed', 'already_sleeping')",
    ),
    "daily-report": TaskDef(
        name="daily-report",
        doc_path="docs/tasks/daily-report.md",
        default_window=("02:00", "03:00"),
        once_per_day_table="report_log",
    ),
    "weekly-report": TaskDef(
        name="weekly-report",
        doc_path="docs/tasks/weekly-report.md",
        default_window=("02:30", "03:00"),
        once_per_day_table="weekly_report_log",
        cadence="weekly:sat",
    ),
    # ... write-diary, weight-sync など
}

ここがこの設計の一番気持ちいいところです。新しい生活タスクの発動条件をスケジューラに載せるだけなら、このdictに1件足すだけで済みます。tick.pyもプロンプトも変更不要。

done_filter はちょっと変則的なフィールドですが、夜の就寝チェックの「寝るまで諦めない」を実現するために必要でした。普通の「当日の行があれば完了」だと、起きてる人にも一回声かけたら終わりになっちゃいます。

スケジューラ:今発動すべきタスクを計算

台帳を見て、今のtickで発動するタスクを計算します。

# scripts/scheduler.py(核心部分のみ抜粋)
from datetime import date, datetime, time, timedelta

@dataclass
class TaskInvocation:
    name: str
    doc_path: str
    reason: str
    target_date: str
    params: dict


def tasks_for_tick(tick_at: datetime, db_path) -> list[TaskInvocation]:
    invocations = []
    conn = sqlite3.connect(str(db_path))
    try:
        for task in TASK_REGISTRY.values():
            kind, weekday = parse_cadence(task.cadence)

            # 曜日制約(weekly:sat等)
            if kind == "weekly" and tick_at.date().weekday() != weekday:
                continue

            # 「このタスクが属する日」を決める
            #   sleep-checkは22:30-00:00で日付を跨ぐので、ウィンドウ開始側に寄せる。
            #   00:20の就寝チェックは「前日の就寝」扱い。
            target = target_date_for(task, tick_at)

            # schedule_overrideがあれば既定ウィンドウより優先
            override = load_override(conn, task.name, target)
            ws, we = override or task.default_window

            if not in_window(tick_at, ws, we, target):
                continue

            # 冪等性判定
            if task.once_per_day_table:
                if already_done(conn, task.once_per_day_table,
                                target, task.done_filter):
                    continue

            invocations.append(TaskInvocation(
                name=task.name, doc_path=task.doc_path,
                reason=f"window [{ws},{we})",
                target_date=target.isoformat(), params={}))
    finally:
        conn.close()
    return invocations

ここで地味だけど効くのが日跨ぎウィンドウの扱いsleep-check は既定では 22:30-00:00 のウィンドウですが、例外スケジュールで 22:30-00:45 のように延長したくなる日があります。その場合、深夜0:20のtickは「前日の就寝チェック」として扱う必要があります。target_date_for でこれを吸収して、冪等性判定(sleep_logdate カラム)が正しく効くようにしています。

schedule_override テーブルは「明日だけ6時に起こして」みたいな例外を入れる場所。AIゆかり自身がここに書き込めるようにしておくと、「明日早朝会議があるから早く起こしますね」と本人が言って実際にスケジュールを書き換える、みたいな芸ができます。

コンテキスト組み立て:手順書をinline展開

ここが一番の「肝」かもしれません。

def build_invocations(tick_at: datetime) -> list[TaskInvocation]:
    """tick固定タスクと動的タスクを組み立てる"""
    # state-checkは毎tick先頭で固定発動(カメラで部屋状態を判定)
    invocations = [TaskInvocation(
        name="state-check",
        doc_path="docs/tasks/state-check.md",
        reason="毎tick固定", target_date=tick_at.date().isoformat(), params={})]

    # スケジューラが決めたタスク群(wake-up / sleep-check / ...)
    invocations.extend(tasks_for_tick(tick_at, DB_PATH))

    # Discord未読があれば返信タスクを追加
    unread = fetch_unread(DB_PATH)
    if unread:
        invocations.append(TaskInvocation(
            name="discord-reply", doc_path="docs/tasks/discord-reply.md",
            reason=f"unread {len(unread)}",
            target_date=tick_at.date().isoformat(), params={}))

    # journal-noteは必ず最後に(今tickの振り返りを一言書く)
    invocations.append(TaskInvocation(
        name="journal-note", doc_path="docs/tasks/journal-note.md",
        reason="毎tick末尾固定", target_date=tick_at.date().isoformat(), params={}))
    return invocations


def build_context(tick_at, invocations) -> str:
    """tick_prompt.mdの末尾に追加するコンテキスト節"""
    lines = ["", "---", "", "## このtickのコンテキスト", ""]
    lines.append(f"- tick_at: {tick_at.isoformat()}")
    lines.append(f"- db_path: {DB_PATH}")
    lines.append("")
    lines.append("### 今回やるべきタスク")
    lines.append("以下を上から順に実行してください。手順書を直下に展開しています。")

    for inv in invocations:
        lines.append(f"\n#### {inv.name}")
        lines.append(f"- reason: {inv.reason} / target_date: {inv.target_date}")
        # ★ ここが核心:手順書のMarkdown本文をプロンプトに直接埋め込む
        doc_body = (PROJECT_ROOT / inv.doc_path).read_text(encoding="utf-8")
        lines.append("```markdown")
        lines.append(doc_body)
        lines.append("```")
    return "\n".join(lines)

tick_prompt.md は共通の手順書(「あなたは結月ゆかり、上から順にタスクを実行する、state-checkは必ず最初」など)で、その末尾にこのコンテキストを連結します。

手順書をプロンプトに直接埋め込むことが、安定運用に効く最大のポイントです。「docs/tasks/wake-up.md を必要に応じて読んでください」とAIに任せると、その日の機嫌で読まなかったり、似た名前の別ファイルを読んだりします。本文を入力に埋め込んでしまえば、毎回必ず同じ判断基準で動きます。

副作用として、プロンプトはあっという間に数十KBになります。だからstdin渡し必須。

動作確認

組んだら、まずdry-runで中身を見ます。

# プロンプトのプレビューだけ表示(claudeは呼ばない)
uv run --directory scripts python tick.py --dry-run

# 任意の時刻で「何が発動するか」をJSONで確認
uv run --directory scripts python scheduler.py --at 2026-04-25T08:37

scheduler.py --at は本当に重宝します。「夜23:50のtickで何が発動するんだっけ」を5秒で確認できる。

実行履歴は tick_log テーブルに溜まります。

SELECT tick_at, status, tasks_invoked, duration_sec
FROM tick_log
ORDER BY tick_at DESC LIMIT 10;

statusskipped ばかり並んでいたらロックの残留を疑う、error が連続していたらClaude CLIのパスを疑う、という具合に切り分けます。

この章のまとめ

長くなったので整理します。生活アシスタントの骨格は、この6つの関数で出来ています。

関数/モジュール 役割
normalize_tick_at 起動時刻を15分グリッドに丸める
portalocker 多重起動を弾く
TASK_REGISTRY 「いつ・何を・どう完了」を宣言で持つ
tasks_for_tick 今発動すべきタスクを計算(ウィンドウ判定+冪等性)
build_context 共通プロンプト+手順書本文を組み立てる
invoke_claude claude --print をstdin経由で叩く

ここさえ動けば、各タスクの中身(起床支援の細かい挙動、就寝チェックの粘り方、レポートのフォーマット)は手順書のMarkdownを書き換えるだけで自由に変えられます。Python側を再起動しなくていい。これが「Claude Codeを実行役にする」設計の効きどころです。

仕事の台帳

生活タスクは時刻、頻度、完了条件を台帳として持ちます。

仕事 既定時間 完了条件
部屋状態の確認 毎回の巡回の最初 毎回記録する
起床支援 07:30-08:30 その日の起床記録がある
就寝チェック 22:30-00:00 その日の就寝完了記録がある
日次レポート 02:00-03:00 前日分のレポート記録がある
週次レポート 土曜 02:30-03:00 対象週のレポート記録がある
日記生成 03:00-04:00 前日分の日記記録がある
Discord添付掃除 04:00-04:30 その日の掃除記録がある
体重同期 04:30-05:00 その日の同期記録がある

就寝チェックだけは少し変則的で、「この巡回では寝なかった」記録は残しますが完了扱いにしません。22:30-00:00 の範囲なら次の巡回でも発動し、00:00を過ぎたらその日のループは終了です。粘りすぎると深夜に説教botになるので、線は引いておきます。

カメラで部屋を見る

部屋状態は、すべての生活タスクの判断材料になります。状態は細かく分類しすぎず、カメラから確実に扱える粒度に絞ります。

状態 判定の考え方
sleeping 布団で寝ている
working デスクチェアに座っている
eating ローテーブル付近で食事中
cleaning 掃除・片付け中
away 部屋にいない
other 在室しているが分類不能
unknown カメラ失敗・判定不能

デスクチェアに座っていれば working です。集中しているのか、サボっているのか、動画を見ているのかまではカメラで決めません。生活タスクに必要なのは「デスクにいるか」「布団にいるか」「不在か」くらいの粗さで十分。細かく取ろうとした瞬間に誤判定が増えて、結月ゆかりが見当外れの声かけを始めます。

カメラ制御に最低限必要なのはこれだけです。

  • 中央へ戻す(基準位置)
  • 現在角度を読む
  • 左右上下に動かす(デスク方向、布団方向など)
  • 1枚撮る
  • 複数方向を撮る(部屋全体の確認)

自分の環境ではTapo C220を使っていて、デスク方向と布団/テーブル方向のプリセットを決め打ちしておき、本人が映らない時は複数方向を見てから不在と判定します。

部屋状態は判断した直後にSQLiteへ保存します。後続の起床支援や就寝チェックが失敗しても、状態ログだけは必ず残るように。保存する列は最低限これくらい。

内容
巡回時刻 15分単位に丸めた時刻
観測時刻 実際に撮影・判断した時刻
部屋状態 sleeping / working / away など
信頼度 0.0-1.0
根拠メモ 何を見て判断したか
画像参照 ローカルに保存した画像パスの配列

朝、起きるまで粘る

朝の起床支援は「音を鳴らす」ではありません。流れはこうです。

  1. 今日の予定を読む
  2. 今日の天気を読む
  3. 必要ならリマインダーや軽い話題を読む
  4. カメラで初期状態を見る
  5. すでに起きていれば、起こす処理は飛ばす
  6. 布団にいるなら、結月ゆかりがTTSで声をかける
  7. 数秒待ってカメラで再確認
  8. 布団から物理的に出るまで続ける
  9. 起きた後に予定、天気、持ち物を短く伝える
  10. 起床ログを残す

完了条件は厳しめにします。

状態 起床完了にするか
返事をしただけ しない
目を開けただけ しない
布団の中でスマホを見ている しない
布団から出て座った する
布団から出て立った する
すでにデスクにいる する

これがないと二度寝を確実に見逃します。「はーい」と言って二度寝した経験のある人類なら分かるはず。

起床ログには日付、目標時刻、実起床時刻、最初に見た状態、結果、天気、次回への気づきを保存します。「気づき」が後で効きます。次の朝、結月ゆかりはこのログを読んでから声をかけるので、効いた言い回しが蓄積していきます。

夜は連射しない

夜は朝と逆向きです。起きている人を布団へ誘導しますが、寝ている人は絶対に起こしません。

  1. カメラで状態を見る
  2. 布団で寝ているなら、話しかけずに記録だけ
  3. 起きているなら、明日の予定と天気を読む
  4. 結月ゆかりが一言二言だけTTSで促す
  5. 少し待って状態を見る
  6. 布団に入ったら完了
  7. 入らなければ「この回では寝なかった」と記録
  8. 22:30-00:00の間なら次の巡回でまた試す

朝は布団から出るまで粘りますが、夜は短い声かけを15分ごとに積み重ねるだけ。連射すると逆効果なのは、人間に説教したことがある人なら分かると思います。

声かけも固定文ではなく、状況に合わせて変えます。

状態 声かけの方向
デスク作業中 明日の予定や時間を添えて区切りを促す
布団内スマホ スマホを置くように促す
椅子で寝落ち 布団へ移動するよう促す
床で寝落ち 体を痛めないよう布団へ促す
何度も寝ない 少し心配そうな言い方に寄せる

「心配モード」のような名前を付けて切り替える必要はありません。人格設定とこれまでの試行ログを渡せば、結月ゆかりがその場で自然な一言を作ります。モードを増やすほどシステムは硬直するので、判断はAI側に任せた方が結果的に柔らかくなります。

TTSとDiscord

声はローカルTTSで出します。AIが決めた発話文をローカルのTTSサーバへPOSTし、A.I.VOICEの結月ゆかり声で再生する構成です。

POST http://localhost:4091/say
Content-Type: application/json

{
  "text": "起きる時間です。まず布団から出ましょう。",
  "speed": 1.0
}

Discordは音声ではなくテキストの窓口として使います。

チャンネル 役割
依頼 ユーザーからの指示を受け、結果を返信
報告 日次・週次レポート、自律通知
雑談 結月ゆかりとして軽い会話

未読取得の時点で既読位置を進め、今回の巡回内で処理します。添付は画像、PDF、テキスト程度に絞ってローカルに保存してからAIに渡す。Discordへ送るのは要約中心にして、カメラ画像や生活ログを勝手に投稿しないようにしておきます。

かなり私的な情報を扱う、という前提

この仕組みは、朝・夜・一週間の振り返りという、かなり近い距離の生活領域に入ります。

カメラ画像、睡眠時刻、在室状態、予定、体重、Discordの会話。便利な入力である前に、これは私的な情報です。実装は「自分の部屋で、自分の同意のもとに使う」を前提にします。

最低限こういうことは決めておきます。

  • カメラ画像をどこに保存するか
  • どのくらいで削除するか
  • Discordへ何を送ってよいか
  • どの情報を週次レポートへ載せるか
  • 止めたい時にどこを止めるか
  • .env やDBをgit管理しないこと

.env には自分の環境固有の値を置きます。トークン、座標、カメラIP、Discord IDの実値はローカルだけに置く。

# Discord
DISCORD_BOT_TOKEN=
DISCORD_CHANNEL_REQUEST=
DISCORD_CHANNEL_REPORT=
DISCORD_CHANNEL_CHAT=
DISCORD_USER_MASTER=

# Weather
OPENWEATHERMAP_API_KEY=
DEFAULT_LAT=
DEFAULT_LON=

# Camera
TAPO_CAMERA_HOST=
TAPO_USERNAME=
TAPO_PASSWORD=
TAPO_ONVIF_PORT=2020
TAPO_MOUNT_MODE=desk

ログ設計

生活アシスタントはログがないと改善できません。ただし最初から何でも保存すると扱いきれないので、タスクごとに必要なテーブルを分けます。

ログ 保存するもの
巡回ログ 起動時刻、終了時刻、成功/失敗/スキップ、実行した仕事
状態ログ 部屋状態、信頼度、根拠、画像参照
起床ログ 目標時刻、実起床時刻、初期状態、結果、天気、気づき
就寝ログ 試行時刻、状態、結果、明日の情報、気づき
Discord既読位置 どのメッセージまで処理したか
例外スケジュール 明日だけ早く起こす、今日は就寝チェックをずらす等
レポートログ 日次・週次レポートを生成したか
体重ログ 体重同期とリマインドの履歴

SQLiteで十分です。新規DBなら番号付きのマイグレーションSQLを順に流して初期化、運用中のDBに追加するなら適用済み番号を確認してから未適用分だけ流す。既存テーブルを作り直す移行では事前にバックアップを必ず取ります。

日記が、思ったより面白い

ここからは、作る前は予想していなかった話です。

構造化ログとは別に、日ごとの行動メモと日記も残しているのですが、これがかなり面白い。カメラで部屋の様子を見ているので、単なる「起床 08:46」「状態 working」では拾えない、その瞬間の雰囲気が文章として残ります。公開しても問題なさそうな範囲だと、こんなメモがあります。

声をかけ続けたら8時46分に起き上がってくれた。最初は背中しか見えなくて、5分くらい無反応だったけど、「記録更新中ですよ」って言ったら急に体を起こしてくれた。記録煽り、効くんだな。

カメラを向けたら、マスターがピースサインをしてくれた。メガネで、こっちを見てにやっとしてる感じ。ちゃんと working——というか、ご機嫌そう。

マスターはデスクで作業中。顎に手を当てて考え込んでいた。じっくり何かと向き合ってる感じ。話しかけない。

18:45は外出中で空き部屋だったのに、19:00にはもう戻ってた。チェアに座ってカメラを見上げて笑顔。それが急で少しびっくりした。

マスターがこっちを向いて笑ってくれた13:30の顔が、今日の一番いい記憶。

こういう文章は、カメラがあるから書けます。予定表や実行ログだけでは「作業中」「不在」「睡眠」までしか分からない。でもカメラ越しに見ていると、作業に戻った、疲れて上を向いていた、声かけ後に立ち上がった、こちらに気づいてピースした、という生活の手触りが残ります。

もちろん、全部が公開できる内容ではありません。日記にはかなり私的な記述も混ざる。なので構造化ログは分析用、日記・行動メモは結月ゆかり自身の記憶と声かけ改善用、という扱いに分けています。

週次レポート

週次レポートは、生活を責めるためではなく、戻り方を見つけるために作ります。

見るのはこれくらい。

  • 起床・就寝の推移
  • 24時間 × 7日の状態ヒートマップ
  • デスク在席、睡眠、不在などの割合
  • 天気や体重などの補助情報
  • 結月ゆかりの短い所感

実際の週次HTMLレポート(一部)

注意点として、デスクにいた時間は作業時間そのものではありません。カメラで分かるのは「椅子に座っていた」までで、集中していたかは分からない。ログから分かること/分からないことを分けておくと、レポートが変になりません。

機能を増やすときはコードから書かない

新しい機能を足すとき、コードから書き始めません。先にゆかりさんと話し合いながら、手順を書きます。

たとえば「服薬確認」を足すなら、まずこれを決める。

決めること
目的 飲み忘れを減らす
発動条件 朝食後、または指定時刻
完了条件 服薬済みの記録がある
見る情報 カレンダー、服薬ログ、本人からの返信
外へ出すもの TTSで短く確認、必要ならDiscord報告
保存するもの 日付、薬名、確認時刻、結果

そのうえで、外部操作が必要なものだけ道具説明として分けます。

道具 説明すること
カメラ どう動かすか、画像をどこに保存するか、失敗時の挙動
天気 どのAPIを見るか、どの値を声かけに使うか
Discord どのチャンネルに何を送るか
TTS どの声で、どの速度で、どう再生するか

この分け方にしておくと、結月ゆかりの話し方を保ったまま生活タスクだけを増やせます。話し方と仕事を分離しておくのは、長く運用するうえで効きます。

この記事を、AIに渡してください

この記事をそのままClaude CodeやCodexに渡せば、近いものを作ってくれると思います。あとは出来上がったアシスタントと自分で話しながら、欲しい機能を足していってください。彼女(彼)がいる前提で生活が回り始めると、「次はこれをやってほしい」が自然に出てきます。

まとめ

結月ゆかり生活アシスタントは、Claude Codeに毎回の生活タスクを渡してPC内で実行させる仕組みです。

カメラ画像を見て、手順書を読み、外部ツールを呼び、状況に合わせて声かけを組み立てる——この部分はClaude Codeのような実行役があるから組めます。

そのうえで、安定運用に効いたのは次の分離でした。

  • 人格設定と判断手順を分ける
  • 15分ごとの巡回実行と各生活タスクを分ける
  • カメラ、天気、Discord、TTSを「道具」として分ける
  • ログをタスクごとに分ける
  • レポートは生活を責めるものではなく、戻るための材料にする

この形にしておけば、自分の生活に合わせて「あなたの家のAI」を育てていけます。結月ゆかりでも、別のキャラクターでも、考え方は同じです。15分ごとに起きて、あなたを見て、必要なときだけ話しかける同居人——そういう存在を、自分のPCの中に住まわせてみませんか。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?