동료가 만든 자동화를 '내 것'으로 포크하기 — 음성 기록을 Obsidian에 의미 있게 올리기

소개 — 무엇을, 왜 시도했나

AI 음성 녹음기(Limitless)로 매일 메모/미팅 내용을 남기려고 노력하는데, 녹음은 쌓이는데 관리/활용을 못하고 있었어요. 음성 녹음기가 잡은 하루치 대화 중 정작 쓸모 있는 건 1%도 안 되고, 그 1%마저 행동으로 이어지질 않았어요.

마침 같은 PKM 코호트 동료가 자신의 PLL(Process Life Logs) 프롬프트를 공유해 주셨습니다. Limitless에서 별표(starred) 표시한 대화만 골라 Obsidian Journal의 Goals/Tasks에 자동 반영하는 워크플로였어요.

그런데 받자마자 그대로 쓰지 않고, 한 단계를 먼저 거치기로 했습니다 — "이걸 왜 이렇게 만들었는지 의도를 역추적하고, 내 환경(여러 프로젝트를 동시에 굴리는 1인 운영)에 맞는지 따져본 뒤 포크한다." 이 글은 그 분석과, 그 과정에서 발견한 '빠진 조각'을 직접 구현한 기록입니다.

진행 방법 — 도구와 활용

사용 도구: Limitless API · Obsidian · Claude(분석·설계·코드) · Claude Code(실행 예정)

1) 받은 프롬프트의 '의도'부터 역추적

코드만 보지 않고 "왜 이렇게 설계했나"를 먼저 분석했습니다. 문서에 남은 단서(파일 경로 규칙, frontmatter 키, 섹션 컨벤션, 아카이빙 루틴)로 읽어낸 설계 철학은 네 가지였어요.

  • 큐레이션은 사람, 라우팅은 AI — 무엇이 중요한지는 AI가 판단하지 않는다. 그 순간 사람이 누른 '별'을 신호로 신뢰한다.

  • Journal은 아카이브가 아니라 작업대 — 인사이트를 어딘가 저장하는 게 아니라, 지금 살아있는 Goals/Tasks에 직접 갖다 붙인다.

  • 복사가 아니라 포인터 — Journal엔 2~3줄 요약 + 링크만. 원본 대화는 캡처 레이어에 두고 그 위에 항해 가능한 인덱스를 얹는다.

  • 매일 안전하게 — 중복 실행 방지(idempotency)를 설계해 하루에 몇 번을 돌려도 피해가 없다.

2) 파이프라인을 3개 레이어로 분해 — 그리고 내 실수 발견

분석하다 중요한 걸 깨달았습니다. 받은 PLL은 전체 파이프라인의 '뒷부분(소비자)' 이라는 것. 입력으로 이미 날짜별로 쪼개지고 별표가 구조화된 파일을 기대하고 있었어요.

레이어

역할

L0 Archive

전체 캡처 콜드 보관

L1 Ingest

하루 단위 분할 + 별표 구조화 ← 받은 문서엔 없는 단계

L2 PLL

L1을 읽어 Journal에 라우팅 ← 동료가 준 부분

여기서 제 시행착오가 드러났습니다. 저는 Limitless API를 연결하자마자 날것 전체를 한꺼번에 Obsidian에 부어버렸거든요. 즉 가운데 L1을 통째로 건너뛴 상태였고, 그 위에 PLL을 그대로 돌리면 시작 조건(starred_list 확인)에서 그냥 종료됩니다. 받은 프롬프트가 안 도는 게 아니라, 먹일 입력이 없었던 거예요.

3) 빠진 L1을 직접 구현

그래서 L1 인제스트를 직접 만들었습니다. 핵심 설계 판단 하나 — 별표만 받지 않고 '하루 전체'를 받되 별표만 마킹한다. PLL이 별표 대화의 전후 맥락을 수집하는 기능을 쓰기 때문에, 별표 아닌 대화도 곁에 있어야 하거든요.

공식 Limitless API는 date 필터, includeMarkdown, 커서 페이지네이션, isStarred 플래그를 모두 제공합니다. 이걸 이용해 하루치를 받아 날짜별 .md로 저장하고, 별표를 frontmatter로 구조화하도록 했어요.

#!/usr/bin/env python3
"""
L1 Ingest — Limitless -> Obsidian

하루치 Limitless lifelog '전체'를 가져와 날짜별 .md 한 파일로 저장하고,
별표(starred)를 frontmatter로 구조화한다.
=> PLL(Process Life Logs)이 수정 없이 바로 소비할 수 있는 입력 형식.

설계:
  - 별표만 받지 않고 '하루 전체'를 받는다. (PLL의 전후 맥락 수집 기능을 살리기 위해)
  - 별표 대화 -> "# ⭐ {title}"  H1 섹션
  - 그 외 대화   -> "# {title}"    H1 섹션
  - frontmatter에 starred_list / starred_conversations 를 기록 -> PLL Phase 0가 이걸 읽는다.

사용:
  export LIMITLESS_API_KEY="sk-..."
  python3 l1_ingest.py                       # 오늘(America/Los_Angeles) 분
  python3 l1_ingest.py --date 2026-06-01     # 특정 날짜
  python3 l1_ingest.py --vault "/path/to/Vault" --tz America/Los_Angeles

출력:
  {vault}/Ingest/Limitless/{date}.md
"""

import os
import sys
import json
import argparse
import urllib.parse
import urllib.request
import urllib.error
from datetime import datetime

try:
    from zoneinfo import ZoneInfo
except ImportError:  # py<3.9 fallback
    ZoneInfo = None

API_BASE = "https://api.limitless.ai/v1/lifelogs"


def today_in(tz_name):
    if ZoneInfo is not None:
        return datetime.now(ZoneInfo(tz_name)).strftime("%Y-%m-%d")
    return datetime.now().strftime("%Y-%m-%d")


def fetch_day(api_key, date, tz_name, per_page=100):
    """해당 날짜의 모든 lifelog를 페이지네이션으로 끝까지 수집."""
    lifelogs = []
    cursor = None
    while True:
        params = {
            "date": date,
            "timezone": tz_name,
            "includeMarkdown": "true",
            "direction": "asc",     # 시간순(전후 맥락 보존에 중요)
            "limit": str(per_page),
        }
        if cursor:
            params["cursor"] = cursor

        url = API_BASE + "?" + urllib.parse.urlencode(params)
        req = urllib.request.Request(url, headers={"X-API-Key": api_key})
        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                payload = json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            sys.exit(f"[L1] API 오류 {e.code}: {e.read().decode('utf-8', 'ignore')}")
        except urllib.error.URLError as e:
            sys.exit(f"[L1] 네트워크 오류: {e.reason}")

        page = payload.get("data", {}).get("lifelogs", []) or []
        lifelogs.extend(page)

        meta = payload.get("meta", {}).get("lifelogs", {}) or {}
        cursor = meta.get("nextCursor")
        if not cursor:
            break

    return lifelogs


def yaml_quote(s):
    """frontmatter 리스트 항목용 안전 따옴표 처리."""
    return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'


def build_markdown(lifelogs, date):
    starred_titles = []
    body = []

    for lg in lifelogs:
        title = (lg.get("title") or "(untitled)").strip()
        is_starred = bool(lg.get("isStarred"))
        if is_starred:
            starred_titles.append(title)
            body.append(f"# ⭐ {title}")
        else:
            body.append(f"# {title}")

        start = lg.get("startTime", "")
        end = lg.get("endTime", "")
        if start or end:
            body.append(f"*{start} — {end}*")
        body.append("")
        body.append((lg.get("markdown") or "").rstrip())
        body.append("")

    generated = datetime.now().astimezone().isoformat(timespec="seconds")

    fm = ["---"]
    fm.append(f"date: {date}")
    fm.append("source: limitless")
    fm.append(f"total_conversations: {len(lifelogs)}")
    fm.append(f"starred_conversations: {len(starred_titles)}")
    if starred_titles:
        fm.append("starred_list:")
        for t in starred_titles:
            fm.append(f"  - {yaml_quote(t)}")
    else:
        fm.append("starred_list: []")
    fm.append(f"generated: {generated}")
    fm.append("---")
    fm.append("")

    return "\n".join(fm) + "\n".join(body), len(starred_titles), len(lifelogs)


def main():
    ap = argparse.ArgumentParser(description="Limitless -> Obsidian L1 ingest")
    ap.add_argument("--date", help="YYYY-MM-DD (기본: 오늘)")
    ap.add_argument("--tz", default="America/Los_Angeles", help="IANA timezone")
    ap.add_argument("--vault", default=".", help="Obsidian vault 루트 경로")
    ap.add_argument("--per-page", type=int, default=100)
    args = ap.parse_args()

    api_key = os.environ.get("LIMITLESS_API_KEY")
    if not api_key:
        sys.exit("[L1] LIMITLESS_API_KEY 환경변수가 필요합니다.")

    date = args.date or today_in(args.tz)
    lifelogs = fetch_day(api_key, date, args.tz, args.per_page)

    md, n_star, n_total = build_markdown(lifelogs, date)

    out_dir = os.path.join(args.vault, "Ingest", "Limitless")
    os.makedirs(out_dir, exist_ok=True)
    out_path = os.path.join(out_dir, f"{date}.md")
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(md)

    print(f"[L1] {out_path}")
    print(f"[L1] 대화 {n_total}건 / 별표 {n_star}건 기록 완료")
    if n_star == 0:
        print("[L1] 별표 0건 -> PLL은 Phase 0에서 정상 EXIT 합니다.")


if __name__ == "__main__":
    main()

출력 형식 검증 (가짜 데이터로 포맷만 확인한 결과 — 실제 펜던트 데이터 아님):

---
date: 2026-06-02
source: limitless
total_conversations: 3
starred_conversations: 1
starred_list:
  - "프로젝트 운영 아이디어"
generated: 2026-06-02T...
---
# 아침 산책 중 어제 일어난 일 소회
*08:10 — 08:25*

> (맥락용 비별표 대화)

# ⭐ 프로젝트 운영 아이디어 팀 미칭
*10:05 — 10:30*

> (별표 누른 핵심 대화)

# 가족과 점심대화
*12:00 — 12:30*

> (맥락용 비별표 대화)

별표 대화는 # ⭐로, 그 외는 # 로 들어가 맥락이 보존되면서 별표만 구분됩니다. frontmatter의 starred_list / starred_conversations를 PLL이 그대로 읽어요.

사용한 프롬프트(L2/PLL)에 대하여: 핵심 PLL 프롬프트는 다른 분의 저작물이라 전문은 원작자 허락을 받아야 해서 생략합니다만 이 글에 실은 코드(L1 인제스트)는 제가 직접 작성한 부분입니다.

결과와 배운 점

현재 상태: 분석·설계·L1 구현·포맷 검증 완료, 실제 라이브 실행(내 볼트에 적용)은 진행 예정입니다. 실행 결과(대화 N건 / 별표 M건 로그 + Journal 반영 화면)는 2편에서 채우겠습니다.

배운 점 / 꿀팁

  • 남의 프롬프트를 쓸 땐 코드보다 '의도'를 먼저 역추적하라. 의도를 알면 어디를 고쳐도 되는지(트레이드오프)와 절대 건드리면 안 되는 핵심이 갈립니다.

  • 잘 만든 자동화는 대개 여러 레이어의 파이프라인이다. 받은 게 그중 어느 레이어인지부터 파악해야 "왜 안 돌지?"의 답이 나옵니다. 제 경우 답은 "L1을 건너뛰어서"였어요.

  • 별표만 뽑으면 안 된다. 직관적으로는 별표만 가져오고 싶지만, 그러면 전후 맥락이 사라져 "왜 이걸 별 눌렀지?"를 나중에 복원할 수 없습니다. 전체를 받되 마킹만 하는 게 정답이었어요.

겪은 시행착오

  • API를 연결하자마자 날것 전체를 다 끌어와 버렸습니다. 가장 필요한 '구조화' 단계가 없어 다음 단계가 통째로 막혔어요. 교훈: 임포트와 정규화는 다른 일이다.

도움이 필요한 부분

  • 미팅 노트나 음성 메모들을 Obsidian에 붙이신 분들은 어떤 방식(직접 API / 플러그인 / 자동화)을 쓰셨고, 메타데이터를 어떻게 살리셨는지 팁을 구합니다.

앞으로의 계획

  • 1차: L1 라이브 실행 → 동료의 PLL(L2) 연결 → Journal 자동 반영 확인.

  • 2차(포크): 여러 프로젝트를 동시에 굴리는 제 환경에 맞게, 오늘 할 일이 아니라 프로젝트 단위 라우팅, task도 메모도 아닌 '글쓰기 씨앗' 레인, 한·영 혼용 대응 이중언어 매칭을 더한 제 버전으로 발전시킬 계획입니다.

밀어주고 끌어주는

온·오프라인 AI 스터디

AI로 어디까지 할 수 있는지
직접 확인하실 분만 신청하세요.