Imun Farmer · Published:
- 예상 수확: 10 min read
pi-mono로 만드는 Windows 기반 회의록 저장·탐색 Agent
pi-mono로 만드는 Windows 기반 회의록 저장·탐색 Agent
회의가 끝나고 10분 뒤, 내용은 이미 반쯤 날아간다. 그 기억을 붙잡는 게 회의록인데, 매번 손으로 쓰고 폴더에 저장하는 일이 얼마나 귀찮은지는 다들 안다. pi-mono를 쓰면 이 흐름 전체를 에이전트 하나가 대신 처리하게 만들 수 있다. 저장도, 검색도, 요약도.
pi-mono가 뭔가
pi-mono는 Mario Zechner(badlogic)가 만든 TypeScript 기반 AI 에이전트 툴킷이다. 단일 패키지가 아니라 레이어 구조로 쌓인 4개의 패키지가 핵심이다.
┌─────────────────────────────────────────┐
│ Your Application │
├────────────────────┬────────────────────┤
│ pi-coding-agent │ pi-tui │
│ Sessions, tools, │ Terminal UI, │
│ extensions │ markdown, editor │
├────────────────────┴────────────────────┤
│ pi-agent-core │
│ Agent loop, tool execution, events │
├─────────────────────────────────────────┤
│ pi-ai │
│ Streaming, models, multi-provider │
└─────────────────────────────────────────┘
- pi-ai: Anthropic, OpenAI, Google, Groq, Ollama 등 2,000개 이상의 모델을 단일 인터페이스로 호출한다. 스트리밍, 비용 추적, 멀티 프로바이더 전환이 한 줄 코드 변경으로 된다.
- pi-agent-core: LLM이 Tool을 호출하고, 결과를 받아서 다음 호출을 반복하는 에이전트 루프를 담당한다.
Agent클래스 하나가 이 루프 전체를 처리한다. - pi-coding-agent: 세션 영속성(JSONL 트리 구조), 내장 파일 도구(read/write/edit/bash), 컨텍스트 자동 압축(compaction), 확장 시스템을 제공한다. 프로덕션 수준의 에이전트를 가장 빠르게 만들 수 있는 레이어다.
- pi-tui: 터미널 UI. 마크다운 렌더링, 슬래시 커맨드 자동완성, 깜빡임 없는 차동 렌더링을 지원한다.
OpenClaw(GitHub Copilot 경쟁 제품)가 이 스택으로 만들어졌다. 현재 npm 최신 버전 기준 @mariozechner/pi-agent-core는 0.61.1이다.
회의록 에이전트 구조 설계
이 에이전트는 세 가지 역할을 한다.
- 저장: 회의 제목, 참석자, 날짜, 내용을 SQLite에 기록
- 탐색: FTS5 전문 검색으로 키워드·날짜·참석자 기반 검색
- 요약: LLM이 긴 회의록을 핵심 3줄로 압축
Windows 환경에서는 데이터 저장 경로를 %USERPROFILE%\Documents\MeetingNotes로 잡는 게 자연스럽다. bash 도구는 Windows에서 Git Bash 또는 WSL이 필요하므로, 이 에이전트는 bash 없이 순수 Node.js fs 모듈 기반 커스텀 도구만 사용한다.
프로젝트 셋업
1. Node.js 환경 준비
# Node.js 20+ 필수 (Windows: winget 또는 nvm-windows 사용)
node -v # v20.x 이상 확인
mkdir meeting-agent && cd meeting-agent
npm init -y
npm install @mariozechner/pi-ai @mariozechner/pi-agent-core @mariozechner/pi-coding-agent
npm install better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3 tsx
2. API 키 설정 (Windows PowerShell)
# 세션 환경변수 (현재 터미널만)
$env:ANTHROPICAPIKEY = "sk-ant-..."
# 영구 등록 (권장)
[System.Environment]::SetEnvironmentVariable("ANTHROPICAPIKEY", "sk-ant-...", "User")
3. tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"outDir": "./dist"
}
}
SQLite 데이터베이스 설계
db.ts – 저장소 초기화
import Database from "better-sqlite3";
import * as path from "path";
import * as os from "os";
import * as fs from "fs";
// Windows: C:\Users\<name>\Documents\MeetingNotes\meetings.db
const DATA_DIR = path.join(os.homedir(), "Documents", "MeetingNotes");
fs.mkdirSync(DATA_DIR, { recursive: true });
const DBPATH = path.join(DATADIR, "meetings.db");
export const db = new Database(DB_PATH);
// 기본 테이블
db.exec(`
CREATE TABLE IF NOT EXISTS meetings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
participants TEXT NOT NULL DEFAULT '',
meeting_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
summary TEXT DEFAULT NULL
);
`);
// FTS5 전문 검색 가상 테이블 (trigram: 한국어 포함 부분 문자열 검색 지원)
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS meetings_fts
USING fts5(
title,
content,
participants,
meeting_date UNINDEXED,
content='meetings',
content_rowid='id',
tokenize='trigram'
);
`);
// 동기화 트리거
db.exec(`
CREATE TRIGGER IF NOT EXISTS meetings_ai
AFTER INSERT ON meetings BEGIN
INSERT INTO meetingsfts(rowid, title, content, participants, meetingdate)
VALUES (new.id, new.title, new.content, new.participants, new.meeting_date);
END;
CREATE TRIGGER IF NOT EXISTS meetings_ad
AFTER DELETE ON meetings BEGIN
INSERT INTO meetingsfts(meetingsfts, rowid, title, content, participants, meeting_date)
VALUES ('delete', old.id, old.title, old.content, old.participants, old.meeting_date);
END;
CREATE TRIGGER IF NOT EXISTS meetings_au
AFTER UPDATE ON meetings BEGIN
INSERT INTO meetingsfts(meetingsfts, rowid, title, content, participants, meeting_date)
VALUES ('delete', old.id, old.title, old.content, old.participants, old.meeting_date);
INSERT INTO meetingsfts(rowid, title, content, participants, meetingdate)
VALUES (new.id, new.title, new.content, new.participants, new.meeting_date);
END;
`);
console.log(`[DB] 초기화 완료: ${DB_PATH}`);
SQLite FTS5의 trigram tokenizer는 한국어 포함 어떤 언어도 부분 문자열 검색이 가능하다. 일반 MATCH는 단어 앞부분만 검색하지만, trigram은 중간 글자도 잡아낸다.
에이전트 도구 정의
tools.ts – 4가지 커스텀 도구
import { Type } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { db } from "./db.js";
// ─── 1. 회의록 저장 ───────────────────────────────────────────────────────────
const saveMeetingParams = Type.Object({
title: Type.String({ description: "회의 제목" }),
content: Type.String({ description: "회의 내용 (마크다운 형식 권장)" }),
participants: Type.String({ description: "참석자 목록 (쉼표 구분)" }),
meeting_date: Type.String({ description: "회의 날짜 (YYYY-MM-DD)" }),
});
export const saveMeetingTool: AgentTool<typeof saveMeetingParams> = {
name: "savemeetingnote",
label: "회의록 저장",
description: "회의 내용을 저장한다. 참석자, 날짜, 제목을 반드시 포함해야 한다.",
parameters: saveMeetingParams,
execute: async (_id, params) => {
const stmt = db.prepare(`
INSERT INTO meetings (title, content, participants, meeting_date)
VALUES (?, ?, ?, ?)
`);
const result = stmt.run(
params.title,
params.content,
params.participants,
params.meeting_date
);
return {
content: [{ type: "text", text: `저장 완료. ID: ${result.lastInsertRowid}` }],
details: { id: result.lastInsertRowid },
};
},
};
// ─── 2. 회의록 검색 ───────────────────────────────────────────────────────────
const searchMeetingParams = Type.Object({
query: Type.String({ description: "검색할 키워드 (제목, 내용, 참석자 모두 검색)" }),
date_from: Type.Optional(Type.String({ description: "검색 시작 날짜 (YYYY-MM-DD)" })),
date_to: Type.Optional(Type.String({ description: "검색 종료 날짜 (YYYY-MM-DD)" })),
limit: Type.Optional(Type.Number({ description: "최대 결과 수", default: 10 })),
});
export const searchMeetingTool: AgentTool<typeof searchMeetingParams> = {
name: "searchmeetingnotes",
label: "회의록 검색",
description: "키워드로 회의록을 전문 검색한다. 날짜 범위 필터를 추가할 수 있다.",
parameters: searchMeetingParams,
execute: async (_id, params) => {
const limit = params.limit ?? 10;
let rows: any[];
// 키워드 이스케이프 후 FTS5 쿼리
const escaped = params.query.replace(/"/g, '""');
const ftsQuery = `"${escaped}"`;
if (params.datefrom || params.dateto) {
const stmt = db.prepare(`
SELECT m.id, m.title, m.participants, m.meeting_date, m.summary,
bm25(meetings_fts) as rank
FROM meetings_fts
JOIN meetings m ON meetings_fts.rowid = m.id
WHERE meetings_fts MATCH ?
AND m.meeting_date BETWEEN ? AND ?
ORDER BY rank
LIMIT ?
`);
rows = stmt.all(
ftsQuery,
params.date_from ?? "0000-01-01",
params.date_to ?? "9999-12-31",
limit
) as any[];
} else {
const stmt = db.prepare(`
SELECT m.id, m.title, m.participants, m.meeting_date, m.summary,
bm25(meetings_fts) as rank
FROM meetings_fts
JOIN meetings m ON meetings_fts.rowid = m.id
WHERE meetings_fts MATCH ?
ORDER BY rank
LIMIT ?
`);
rows = stmt.all(ftsQuery, limit) as any[];
}
if (rows.length === 0) {
return {
content: [{ type: "text", text: "검색 결과가 없다." }],
details: { count: 0 },
};
}
const formatted = rows
.map(
(r) =>
`[ID:${r.id}] ${r.meeting_date} | ${r.title} | 참석: ${r.participants}`
)
.join("\n");
return {
content: [{ type: "text", text: `검색 결과 ${rows.length}건:\n${formatted}` }],
details: { count: rows.length, rows },
};
},
};
// ─── 3. 회의록 단건 조회 ──────────────────────────────────────────────────────
const getMeetingParams = Type.Object({
id: Type.Number({ description: "회의록 ID" }),
});
export const getMeetingTool: AgentTool<typeof getMeetingParams> = {
name: "getmeetingnote",
label: "회의록 조회",
description: "특정 ID의 회의록 전체 내용을 가져온다.",
parameters: getMeetingParams,
execute: async (_id, params) => {
const row = db
.prepare("SELECT * FROM meetings WHERE id = ?")
.get(params.id) as any;
if (!row) {
return {
content: [{ type: "text", text: `ID ${params.id}의 회의록이 없다.` }],
details: {},
};
}
const text = [
`# ${row.title}`,
`날짜: ${row.meeting_date}`,
`참석자: ${row.participants}`,
`저장일시: ${row.created_at}`,
"",
row.content,
row.summary ? `\n---\n요약: ${row.summary}` : "",
].join("\n");
return {
content: [{ type: "text", text }],
details: { id: row.id, title: row.title },
};
},
};
// ─── 4. 회의록 목록 ───────────────────────────────────────────────────────────
const listMeetingsParams = Type.Object({
limit: Type.Optional(Type.Number({ description: "페이지당 건수", default: 20 })),
offset: Type.Optional(Type.Number({ description: "시작 오프셋", default: 0 })),
});
export const listMeetingsTool: AgentTool<typeof listMeetingsParams> = {
name: "listmeetingnotes",
label: "회의록 목록",
description: "저장된 회의록 목록을 최신순으로 반환한다.",
parameters: listMeetingsParams,
execute: async (_id, params) => {
const limit = params.limit ?? 20;
const offset = params.offset ?? 0;
const rows = db
.prepare(
`SELECT id, title, participants, meetingdate, createdat
FROM meetings
ORDER BY meetingdate DESC, createdat DESC
LIMIT ? OFFSET ?`
)
.all(limit, offset) as any[];
const totalRow = db
.prepare("SELECT COUNT(*) as cnt FROM meetings")
.get() as any;
if (rows.length === 0) {
return {
content: [{ type: "text", text: "저장된 회의록이 없다." }],
details: { total: 0 },
};
}
const formatted = rows
.map((r) => `[${r.id}] ${r.meeting_date} ${r.title} (${r.participants})`)
.join("\n");
return {
content: [
{
type: "text",
text: `전체 ${totalRow.cnt}건 중 ${rows.length}건 표시:\n${formatted}`,
},
],
details: { total: totalRow.cnt, rows },
};
},
};
도구가 4개다. LLM 입장에서는 저장·검색·조회·목록 네 가지 능력을 가진 에이전트가 된다. bm25() 함수는 FTS5 기본 내장 함수로, 숫자가 낮을수록 관련도가 높은 결과다.
에이전트 메인 파일
agent.ts – 전체 진입점
import {
createAgentSession,
SessionManager,
} from "@mariozechner/pi-coding-agent";
import { getModel, streamSimple } from "@mariozechner/pi-ai";
import * as path from "path";
import * as os from "os";
import * as fs from "fs";
import * as readline from "readline";
import {
saveMeetingTool,
searchMeetingTool,
getMeetingTool,
listMeetingsTool,
} from "./tools.js";
// 세션 파일 경로 (Windows: Documents\MeetingNotes\.sessions\)
const SESSION_DIR = path.join(
os.homedir(),
"Documents",
"MeetingNotes",
".sessions"
);
fs.mkdirSync(SESSION_DIR, { recursive: true });
const SESSIONFILE = path.join(SESSIONDIR, "meeting-agent.jsonl");
const SYSTEM_PROMPT = `
너는 회의록 관리 전문 에이전트다.
사용자가 회의 내용을 말하면 저장하고, 이전 회의를 찾아달라고 하면 검색한다.
규칙:
- 저장 요청 시, 제목/날짜/참석자를 반드시 확인하고 savemeetingnote를 호출한다.
- 날짜가 없으면 오늘 날짜(${new Date().toISOString().split("T")[0]})를 사용한다.
- 검색 시 searchmeetingnotes 도구를 쓴다. 결과를 간결하게 요약해서 보여준다.
- 회의록 전체 내용이 필요하면 getmeetingnote(id)를 쓴다.
- 모든 응답은 한국어로 한다.
`.trim();
async function main() {
const model = getModel("anthropic", "claude-opus-4-5");
const { session } = await createAgentSession({
model,
thinkingLevel: "off",
sessionManager: SessionManager.open(SESSION_FILE),
customTools: [
saveMeetingTool,
searchMeetingTool,
getMeetingTool,
listMeetingsTool,
],
});
// 시스템 프롬프트 주입
session.agent.state.systemPrompt = SYSTEM_PROMPT;
session.agent.streamFn = streamSimple;
// 이벤트 핸들러
session.subscribe((event) => {
switch (event.type) {
case "message_update":
if (event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
break;
case "toolexecutionstart":
process.stdout.write(`\n [${event.toolName}] 실행 중...\n`);
break;
case "toolexecutionend":
if (event.isError) {
process.stdout.write(` 오류 발생\n`);
}
break;
case "agent_end":
process.stdout.write("\n");
break;
}
});
const msgCount = session.messages.length;
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" 📋 회의록 에이전트 (pi-mono 기반)");
console.log(` 모델: ${model.id}`);
console.log(` 세션: ${SESSION_FILE}`);
console.log(` 대화 기록: ${msgCount}건`);
console.log(" 명령: 'exit' 종료 | 'new' 세션 초기화 | 'list' 목록");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ask = () => {
rl.question("사용자: ", async (input) => {
const trimmed = input.trim();
if (!trimmed) return ask();
if (trimmed === "exit") {
session.dispose();
rl.close();
console.log("종료.");
return;
}
if (trimmed === "new") {
await session.newSession();
console.log("새 세션 시작.\n");
return ask();
}
if (trimmed === "list") {
await session.prompt("저장된 회의록 목록을 보여줘.");
return ask();
}
try {
process.stdout.write("에이전트: ");
await session.prompt(trimmed);
} catch (err: any) {
console.error(`오류: ${err.message}`);
}
ask();
});
};
ask();
}
main();
실행 방법
# 개발 실행 (tsx 사용)
npx tsx agent.ts
# 빌드 후 실행
npx tsc && node dist/agent.js
실제 사용 예시
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 회의록 에이전트 (pi-mono 기반)
모델: claude-opus-4-5
세션: C:\Users\장헌수\Documents\MeetingNotes\.sessions\meeting-agent.jsonl
대화 기록: 0건
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
사용자: 오늘 오전에 배포 일정 회의 있었어. 참석자는 박개발, 김디자인, 이PM이고
다음 달 5일 v2.0 배포 확정했어. QA는 28일까지 완료 예정.
에이전트:
[savemeetingnote] 실행 중...
회의록을 저장했다. (ID: 1)
- 제목: v2.0 배포 일정 회의
- 날짜: 2026-03-23
- 참석자: 박개발, 김디자인, 이PM
- 주요 결정: 4월 5일 v2.0 배포 확정, QA 3월 28일 완료 목표
사용자: 지난 달 배포 관련 회의 찾아줘
에이전트:
[searchmeetingnotes] 실행 중...
검색 결과 1건: 2026-03-23 | v2.0 배포 일정 회의 | 참석: 박개발, 김디자인, 이PM
확장 아이디어
세 가지 방향으로 더 나아갈 수 있다.
- 자동 요약 Extension 추가
pi-coding-agent의 Extension 시스템을 사용해, 저장 직후 LLM이 자동으로 3줄 요약을 생성하고 summary 컬럼에 업데이트한다.
// extensions/auto-summary.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function autoSummaryExtension(api: ExtensionAPI): void {
api.on("tool_call", async (event, ctx) => {
if (event.toolName === "savemeetingnote" && !event.isError) {
const savedId = (event.result as any)?.details?.id;
if (savedId) {
// 저장 완료 후 요약 요청을 follow-up으로 주입
await ctx.session.followUp(
`방금 저장한 회의록(ID:${savedId})을 3줄로 요약해서 summary 필드에 업데이트해줘.`
);
}
}
});
}
- 마크다운 내보내기 도구 추가
exportmeetingnote(id, outputPath) 도구를 만들어 회의록을 .md 파일로 내보내면, Obsidian·Notion과 연동할 수 있다.
- pi-tui로 터미널 UI 업그레이드
@mariozechner/pi-tui를 추가하면 마크다운 렌더링, 슬래시 커맨드 자동완성, 파일명 자동완성이 가능한 풍부한 터미널 UI를 30줄 추가로 구현할 수 있다.
Windows 환경 주의사항
| 항목 | 주의사항 |
|---|---|
| Node.js 버전 | 반드시 20+ 사용. winget 또는 nvm-windows로 설치 |
| 경로 구분자 | path.join() 사용 시 자동 처리됨. 하드코딩 금지 |
| bash 도구 | pi-coding-agent 내장 bash 도구는 Windows에서 Git Bash / WSL 필요. 이 에이전트는 bash 미사용 |
| 파일 인코딩 | better-sqlite3 기본 UTF-8. 한국어 저장·검색 모두 정상 동작 |
| JSONL 세션 | append-only 구조라 갑작스러운 종료에도 마지막 1줄만 손실 |
| 환경변수 | PowerShell [System.Environment]::SetEnvironmentVariable로 영구 등록 |
참고 자료
- pi-mono 공식 GitHub – badlogic(Mario Zechner) 저장소
- How to Build a Custom Agent Framework with PI – Nader Dabit – pi-mono 레이어별 상세 튜토리얼
- pi-mono SDK 공식 문서 – AgentSession, SessionManager 전체 API
- What I learned building an opinionated and minimal coding agent – mariozechner.at – 설계 철학 및 아키텍처 결정 배경
- Pi: The Minimal Agent Within OpenClaw – Armin Ronacher – OpenClaw 엔진으로서의 Pi 분석
- SQLite FTS5 전문 검색 가이드 – javaexpert.tistory.com – TypeScript better-sqlite3 FTS5 구현 예시
- @mariozechner/pi-agent-core on npm – 최신 버전 0.61.1
Contribution to this Harvest
내용이 유익했다면 물을 주어 글을 성장시켜주세요!
(0개의 물방울이 모였습니다)