IMUN.FARM

Imun Farmer · Published:

- 예상 수확: 10 min read

pi-mono로 만드는 Windows 기반 회의록 저장·탐색 Agent

img of 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이다.


회의록 에이전트 구조 설계

이 에이전트는 세 가지 역할을 한다.

  1. 저장: 회의 제목, 참석자, 날짜, 내용을 SQLite에 기록
  2. 탐색: FTS5 전문 검색으로 키워드·날짜·참석자 기반 검색
  3. 요약: 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

확장 아이디어

세 가지 방향으로 더 나아갈 수 있다.

  1. 자동 요약 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 필드에 업데이트해줘.`
 );
 }
 }
 });
}
  1. 마크다운 내보내기 도구 추가

exportmeetingnote(id, outputPath) 도구를 만들어 회의록을 .md 파일로 내보내면, Obsidian·Notion과 연동할 수 있다.

  1. 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로 영구 등록

참고 자료

Related Posts

There are no related posts yet. 😢

Contribution to this Harvest

내용이 유익했다면 물을 주어 글을 성장시켜주세요!
(0개의 물방울이 모였습니다)

Seed