odab-note 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odab_note/__init__.py +1 -0
- odab_note/cli.py +193 -0
- odab_note/database.py +298 -0
- odab_note/server.py +218 -0
- odab_note-0.1.0.dist-info/METADATA +339 -0
- odab_note-0.1.0.dist-info/RECORD +8 -0
- odab_note-0.1.0.dist-info/WHEEL +4 -0
- odab_note-0.1.0.dist-info/entry_points.txt +3 -0
odab_note/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# odab-note package
|
odab_note/cli.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from odab_note.database import OdabNoteDB
|
|
3
|
+
from odab_note.server import mcp
|
|
4
|
+
|
|
5
|
+
db = OdabNoteDB()
|
|
6
|
+
|
|
7
|
+
@click.group()
|
|
8
|
+
def main():
|
|
9
|
+
"""OdabNote CLI - Manage wrong-answer notes (오답노트) for AI Agents."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
@main.command()
|
|
13
|
+
def list():
|
|
14
|
+
"""List all wrong-answer notes."""
|
|
15
|
+
notes = db.list_all_notes()
|
|
16
|
+
if not notes:
|
|
17
|
+
click.echo("No wrong-answer notes found.")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
click.echo(f"{'ID':<4} | {'Keyword':<15} | {'Model':<15} | {'Count':<5} | {'Verified':<8} | {'Error Pattern'}")
|
|
21
|
+
click.echo("-" * 95)
|
|
22
|
+
for n in notes:
|
|
23
|
+
verified = "Yes" if n['is_verified'] else "No"
|
|
24
|
+
model = n.get('target_model', 'all')
|
|
25
|
+
# 에러 패턴은 첫 줄만 요약해서 보여줌
|
|
26
|
+
err_summary = n['error_pattern'].split('\n')[0]
|
|
27
|
+
if len(err_summary) > 40:
|
|
28
|
+
err_summary = err_summary[:37] + "..."
|
|
29
|
+
click.echo(f"{n['id']:<4} | {n['keyword']:<15} | {model:<15} | {n['occurrence_count']:<5} | {verified:<8} | {err_summary}")
|
|
30
|
+
|
|
31
|
+
@main.command()
|
|
32
|
+
@click.argument('note_id', type=int)
|
|
33
|
+
def show(note_id):
|
|
34
|
+
"""Show details of a specific wrong-answer note."""
|
|
35
|
+
notes = db.list_all_notes()
|
|
36
|
+
note = next((n for n in notes if n['id'] == note_id), None)
|
|
37
|
+
if not note:
|
|
38
|
+
click.echo(f"Note with ID {note_id} not found.")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
verified = "Yes" if note['is_verified'] else "No"
|
|
42
|
+
click.echo(f"ID: {note['id']}")
|
|
43
|
+
click.echo(f"Keyword: {note['keyword']}")
|
|
44
|
+
click.echo(f"Occurrence Count: {note['occurrence_count']}")
|
|
45
|
+
click.echo(f"Verified (Veto Passed): {verified}")
|
|
46
|
+
click.echo("-" * 40)
|
|
47
|
+
click.echo(f"Error Pattern:\n{note['error_pattern']}")
|
|
48
|
+
click.echo("-" * 40)
|
|
49
|
+
click.echo(f"Correct Solution:\n{note['solution']}")
|
|
50
|
+
|
|
51
|
+
@main.command()
|
|
52
|
+
@click.option('--keyword', '-k', required=True, help="Target keyword or file name.")
|
|
53
|
+
@click.option('--error', '-e', required=True, help="Error or mistake pattern.")
|
|
54
|
+
@click.option('--fix', '-f', required=True, help="Correct action or fix pattern.")
|
|
55
|
+
@click.option('--model', '-m', default='all', help="Target AI model (e.g. claude-3.5-sonnet, gemini-1.5-pro, all).")
|
|
56
|
+
def add(keyword, error, fix, model):
|
|
57
|
+
"""Add a new wrong-answer note manually (auto-verified)."""
|
|
58
|
+
note_id = db.add_mistake(keyword, error, fix, target_model=model, is_verified=True)
|
|
59
|
+
click.echo(f"Successfully added verified note (ID: {note_id}, Model: {model}).")
|
|
60
|
+
|
|
61
|
+
@main.command()
|
|
62
|
+
@click.argument('mistake')
|
|
63
|
+
@click.argument('fix')
|
|
64
|
+
@click.option('--model', '-m', default='all', help="Which model made this mistake.")
|
|
65
|
+
def pull(mistake, fix, model):
|
|
66
|
+
"""Quick-record: odab pull 'what went wrong' 'what fixed it'"""
|
|
67
|
+
import re
|
|
68
|
+
clean = re.sub(r'[^\w\s]', '', mistake)
|
|
69
|
+
words = [w.capitalize() for w in clean.split() if len(w) > 2][:4]
|
|
70
|
+
keyword = "_".join(words) if words else "Unknown_Error"
|
|
71
|
+
note_id = db.add_mistake(keyword, mistake, fix, target_model=model, is_verified=True)
|
|
72
|
+
click.echo(f"✅ Recorded (ID: {note_id}, Model: {model})")
|
|
73
|
+
click.echo(f" Keyword: {keyword}")
|
|
74
|
+
click.echo(f" Mistake: {mistake}")
|
|
75
|
+
click.echo(f" Fix: {fix}")
|
|
76
|
+
|
|
77
|
+
@main.command()
|
|
78
|
+
@click.argument('note_id', type=int)
|
|
79
|
+
def approve(note_id):
|
|
80
|
+
"""Approve a wrong-answer note (Veto Pass)."""
|
|
81
|
+
if db.update_verification(note_id, True):
|
|
82
|
+
click.echo(f"Note {note_id} approved successfully.")
|
|
83
|
+
else:
|
|
84
|
+
click.echo(f"Failed to approve note {note_id} (not found).")
|
|
85
|
+
|
|
86
|
+
@main.command()
|
|
87
|
+
@click.argument('note_id', type=int)
|
|
88
|
+
def delete(note_id):
|
|
89
|
+
"""Delete a wrong-answer note."""
|
|
90
|
+
if db.delete_note(note_id):
|
|
91
|
+
click.echo(f"Note {note_id} deleted successfully.")
|
|
92
|
+
else:
|
|
93
|
+
click.echo(f"Failed to delete note {note_id} (not found).")
|
|
94
|
+
|
|
95
|
+
@main.command()
|
|
96
|
+
@click.argument('note_id_a', type=int)
|
|
97
|
+
@click.argument('note_id_b', type=int)
|
|
98
|
+
@click.option('--solution-c', '-c', required=True, help="Proposed merged solution C.")
|
|
99
|
+
def resolve(note_id_a, note_id_b, solution_c):
|
|
100
|
+
"""Interactively resolve conflict between Note A and Note B."""
|
|
101
|
+
notes = db.list_all_notes()
|
|
102
|
+
note_a = next((n for n in notes if n['id'] == note_id_a), None)
|
|
103
|
+
note_b = next((n for n in notes if n['id'] == note_id_b), None)
|
|
104
|
+
|
|
105
|
+
if not note_a or not note_b:
|
|
106
|
+
click.echo("Error: One or both notes not found.")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
click.echo("\n[Odab-Note Conflict Alert 🚨]")
|
|
110
|
+
click.echo(f"- Note A (Existing/Failed): [ID: {note_id_a}] Keyword: {note_a['keyword']}")
|
|
111
|
+
click.echo(f" * Error: {note_a['error_pattern']}")
|
|
112
|
+
click.echo(f" * Solution: {note_a['solution']}")
|
|
113
|
+
click.echo("-" * 40)
|
|
114
|
+
click.echo(f"- Note B (New/Recent): [ID: {note_id_b}] Keyword: {note_b['keyword']}")
|
|
115
|
+
click.echo(f" * Error: {note_b['error_pattern']}")
|
|
116
|
+
click.echo(f" * Solution: {note_b['solution']}")
|
|
117
|
+
click.echo("-" * 40)
|
|
118
|
+
click.echo(f"- Proposed Merged Option C:")
|
|
119
|
+
click.echo(f" * Solution: {solution_c}")
|
|
120
|
+
click.echo("-" * 40)
|
|
121
|
+
|
|
122
|
+
click.echo("\nHow would you like to resolve this conflict?")
|
|
123
|
+
click.echo("1. Keep Note A (Existing/Failed) and discard B")
|
|
124
|
+
click.echo("2. Keep Note B (New/Recent) and discard A")
|
|
125
|
+
click.echo("3. Merge A & B into Option C (New Solution)")
|
|
126
|
+
|
|
127
|
+
choice = click.prompt("Enter option", type=click.Choice(['1', '2', '3']))
|
|
128
|
+
|
|
129
|
+
if choice == '1':
|
|
130
|
+
db.delete_note(note_id_b)
|
|
131
|
+
click.echo(f"Resolved: Kept Note {note_id_a}, deleted Note {note_id_b}.")
|
|
132
|
+
elif choice == '2':
|
|
133
|
+
db.delete_note(note_id_a)
|
|
134
|
+
# Note B를 verified 처리
|
|
135
|
+
db.update_verification(note_id_b, True)
|
|
136
|
+
click.echo(f"Resolved: Kept Note {note_id_b}, deleted Note {note_id_a}.")
|
|
137
|
+
elif choice == '3':
|
|
138
|
+
db.merge_and_replace_notes(note_id_a, note_id_b, solution_c, note_a['keyword'])
|
|
139
|
+
click.echo(f"Resolved: Note {note_id_b} merged into Note {note_id_a} with Merged Solution C.")
|
|
140
|
+
|
|
141
|
+
@main.command()
|
|
142
|
+
@click.option('--days', '-d', default=7, type=int, help="Days threshold for time decay.")
|
|
143
|
+
def decay(days):
|
|
144
|
+
"""Manually apply time-decay to decrease weights of obsolete notes."""
|
|
145
|
+
affected = db.apply_decay(days_threshold=days)
|
|
146
|
+
click.echo(f"Applied time decay (Threshold: {days} days). Affected {affected} notes.")
|
|
147
|
+
|
|
148
|
+
@main.command()
|
|
149
|
+
@click.argument('from_id', type=int)
|
|
150
|
+
@click.argument('to_id', type=int)
|
|
151
|
+
@click.option('--type', '-t', default='triggers', type=click.Choice(['conflict', 'triggers']), help="Relation type (conflict or triggers).")
|
|
152
|
+
def link(from_id, to_id, type):
|
|
153
|
+
"""Link two notes with relation (triggers or conflict)."""
|
|
154
|
+
db.add_relation(from_id, to_id, type)
|
|
155
|
+
click.echo(f"Successfully linked Note {from_id} -> Note {to_id} as '{type}'.")
|
|
156
|
+
|
|
157
|
+
@main.command()
|
|
158
|
+
def graph():
|
|
159
|
+
"""Visualize relations and conflicts of wrong-answer notes as a network tree."""
|
|
160
|
+
notes = db.list_all_notes()
|
|
161
|
+
if not notes:
|
|
162
|
+
click.echo("No wrong-answer notes to visualize.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
click.echo("\n[ Odab-Note Dependency & Conflict Graph 🕸️ ]\n")
|
|
166
|
+
for note in notes:
|
|
167
|
+
note_id = note['id']
|
|
168
|
+
conflicts = db.get_related_notes_by_type(note_id, 'conflict')
|
|
169
|
+
triggers = db.get_related_notes_by_type(note_id, 'triggers')
|
|
170
|
+
|
|
171
|
+
# 모델 정보가 all이 아닐 경우 괄호로 추가 표시
|
|
172
|
+
model_str = f" ({note['target_model']})" if note.get('target_model') and note['target_model'] != 'all' else ""
|
|
173
|
+
verified_str = "✓" if note['is_verified'] else "✗"
|
|
174
|
+
|
|
175
|
+
click.echo(f"● [ID: {note_id}] {note['keyword']}{model_str} (Count: {note['occurrence_count']}, Verified: {verified_str})")
|
|
176
|
+
|
|
177
|
+
for c in conflicts:
|
|
178
|
+
click.echo(f" ├── ⚡ [Conflict] -> [ID: {c['id']}] {c['keyword']}")
|
|
179
|
+
for t in triggers:
|
|
180
|
+
click.echo(f" └── 🔗 [Triggers] -> [ID: {t['id']}] {t['keyword']}")
|
|
181
|
+
|
|
182
|
+
if not conflicts and not triggers:
|
|
183
|
+
click.echo(" └── (No active relations)")
|
|
184
|
+
click.echo("")
|
|
185
|
+
|
|
186
|
+
@main.command()
|
|
187
|
+
def run_server():
|
|
188
|
+
"""Run the OdabNote MCP server."""
|
|
189
|
+
click.echo("Starting OdabNote MCP Server...")
|
|
190
|
+
mcp.run()
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|
odab_note/database.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
DEFAULT_DB_PATH = os.path.expanduser("~/.gemini/antigravity/odab_note.db")
|
|
8
|
+
|
|
9
|
+
class OdabNoteDB:
|
|
10
|
+
def __init__(self, db_path: str = DEFAULT_DB_PATH):
|
|
11
|
+
self.db_path = db_path
|
|
12
|
+
# 디렉토리가 없으면 생성
|
|
13
|
+
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
|
14
|
+
self.init_db()
|
|
15
|
+
|
|
16
|
+
def _get_connection(self):
|
|
17
|
+
conn = sqlite3.connect(self.db_path)
|
|
18
|
+
conn.row_factory = sqlite3.Row
|
|
19
|
+
return conn
|
|
20
|
+
|
|
21
|
+
def init_db(self):
|
|
22
|
+
with self._get_connection() as conn:
|
|
23
|
+
# 1. 오답노트 테이블 (last_occurred_at, decay_factor, target_model 속성 기본 포함)
|
|
24
|
+
conn.execute("""
|
|
25
|
+
CREATE TABLE IF NOT EXISTS incorrect_notes (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
keyword TEXT NOT NULL,
|
|
28
|
+
error_pattern TEXT NOT NULL,
|
|
29
|
+
solution TEXT NOT NULL,
|
|
30
|
+
occurrence_count INTEGER DEFAULT 1,
|
|
31
|
+
is_verified BOOLEAN DEFAULT 0,
|
|
32
|
+
last_occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
33
|
+
decay_factor REAL DEFAULT 0.1,
|
|
34
|
+
target_model TEXT DEFAULT 'all'
|
|
35
|
+
)
|
|
36
|
+
""")
|
|
37
|
+
# 2. 세션 메모리 테이블
|
|
38
|
+
conn.execute("""
|
|
39
|
+
CREATE TABLE IF NOT EXISTS session_memories (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
project TEXT NOT NULL,
|
|
42
|
+
summary TEXT NOT NULL,
|
|
43
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
44
|
+
)
|
|
45
|
+
""")
|
|
46
|
+
# 3. 에이전트 스킬 테이블
|
|
47
|
+
conn.execute("""
|
|
48
|
+
CREATE TABLE IF NOT EXISTS agent_skills (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
name TEXT UNIQUE NOT NULL,
|
|
51
|
+
cmd TEXT NOT NULL,
|
|
52
|
+
desc TEXT NOT NULL,
|
|
53
|
+
is_verified BOOLEAN DEFAULT 0
|
|
54
|
+
)
|
|
55
|
+
""")
|
|
56
|
+
# 4. 오답 노드 간의 관계 및 상충(Conflict) 테이블
|
|
57
|
+
conn.execute("""
|
|
58
|
+
CREATE TABLE IF NOT EXISTS incorrect_note_relations (
|
|
59
|
+
from_note_id INTEGER,
|
|
60
|
+
to_note_id INTEGER,
|
|
61
|
+
relation_type TEXT NOT NULL, -- 'conflict', 'triggers', 'parent_of'
|
|
62
|
+
PRIMARY KEY (from_note_id, to_note_id),
|
|
63
|
+
FOREIGN KEY (from_note_id) REFERENCES incorrect_notes(id) ON DELETE CASCADE,
|
|
64
|
+
FOREIGN KEY (to_note_id) REFERENCES incorrect_notes(id) ON DELETE CASCADE
|
|
65
|
+
)
|
|
66
|
+
""")
|
|
67
|
+
conn.commit()
|
|
68
|
+
|
|
69
|
+
# 스키마 마이그레이션 (기존 생성 테이블용 하위 호환성 패치)
|
|
70
|
+
with self._get_connection() as conn:
|
|
71
|
+
try:
|
|
72
|
+
conn.execute("ALTER TABLE incorrect_notes ADD COLUMN last_occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
|
73
|
+
except sqlite3.OperationalError:
|
|
74
|
+
pass
|
|
75
|
+
try:
|
|
76
|
+
conn.execute("ALTER TABLE incorrect_notes ADD COLUMN decay_factor REAL DEFAULT 0.1")
|
|
77
|
+
except sqlite3.OperationalError:
|
|
78
|
+
pass
|
|
79
|
+
try:
|
|
80
|
+
conn.execute("ALTER TABLE incorrect_notes ADD COLUMN target_model TEXT DEFAULT 'all'")
|
|
81
|
+
except sqlite3.OperationalError:
|
|
82
|
+
pass
|
|
83
|
+
conn.commit()
|
|
84
|
+
|
|
85
|
+
# --- incorrect_notes CRUD ---
|
|
86
|
+
def add_mistake(self, keyword: str, error_pattern: str, solution: str, target_model: str = 'all', is_verified: bool = False) -> int:
|
|
87
|
+
with self._get_connection() as conn:
|
|
88
|
+
# 기존에 동일한 keyword와 error_pattern, target_model이 있는지 확인하여 카운트 증가
|
|
89
|
+
cursor = conn.execute("""
|
|
90
|
+
SELECT id, occurrence_count FROM incorrect_notes
|
|
91
|
+
WHERE keyword = ? AND error_pattern = ? AND target_model = ?
|
|
92
|
+
""", (keyword, error_pattern, target_model))
|
|
93
|
+
row = cursor.fetchone()
|
|
94
|
+
if row:
|
|
95
|
+
note_id = row["id"]
|
|
96
|
+
conn.execute("""
|
|
97
|
+
UPDATE incorrect_notes
|
|
98
|
+
SET occurrence_count = occurrence_count + 1, solution = ?, last_occurred_at = CURRENT_TIMESTAMP
|
|
99
|
+
WHERE id = ?
|
|
100
|
+
""", (solution, note_id))
|
|
101
|
+
conn.commit()
|
|
102
|
+
return note_id
|
|
103
|
+
else:
|
|
104
|
+
cursor = conn.execute("""
|
|
105
|
+
INSERT INTO incorrect_notes (keyword, error_pattern, solution, is_verified, last_occurred_at, decay_factor, target_model)
|
|
106
|
+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, 0.1, ?)
|
|
107
|
+
""", (keyword, error_pattern, solution, 1 if is_verified else 0, target_model))
|
|
108
|
+
conn.commit()
|
|
109
|
+
return cursor.lastrowid
|
|
110
|
+
|
|
111
|
+
def query_notes(self, keywords: List[str], only_verified: bool = False) -> List[Dict[str, Any]]:
|
|
112
|
+
if not keywords:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
# 키워드 매칭 쿼리 (OR 조건)
|
|
116
|
+
query = "SELECT * FROM incorrect_notes WHERE (" + " OR ".join(["keyword LIKE ?" for _ in keywords]) + ")"
|
|
117
|
+
params = [f"%{k}%" for k in keywords]
|
|
118
|
+
|
|
119
|
+
if only_verified:
|
|
120
|
+
query += " AND is_verified = 1"
|
|
121
|
+
|
|
122
|
+
query += " ORDER BY occurrence_count DESC"
|
|
123
|
+
|
|
124
|
+
with self._get_connection() as conn:
|
|
125
|
+
cursor = conn.execute(query, params)
|
|
126
|
+
notes = [dict(row) for row in cursor.fetchall()]
|
|
127
|
+
|
|
128
|
+
# 각 오답노트의 2차 연관 및 상충 오답들도 가져와 주입
|
|
129
|
+
for note in notes:
|
|
130
|
+
note['conflicts'] = self.get_related_notes_by_type(note['id'], 'conflict')
|
|
131
|
+
note['relations'] = self.get_related_notes_by_type(note['id'], 'triggers')
|
|
132
|
+
return notes
|
|
133
|
+
|
|
134
|
+
def match_error_trace(self, error_trace: str, target_model: str = 'all', only_verified: bool = False) -> List[Dict[str, Any]]:
|
|
135
|
+
"""Match error trace against wrong-answer note regex patterns and retrieve matches."""
|
|
136
|
+
notes = self.list_all_notes()
|
|
137
|
+
matched_notes = []
|
|
138
|
+
for note in notes:
|
|
139
|
+
if only_verified and not note['is_verified']:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
# target_model 필터링 (all이거나 혹은 요청한 모델명과 매칭될 때만)
|
|
143
|
+
note_model = note.get('target_model', 'all')
|
|
144
|
+
if target_model != 'all' and note_model != 'all' and note_model != target_model:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# 정규식 대소문자 구분 없이, 줄바꿈도 매치하도록 컴파일
|
|
149
|
+
pattern = re.compile(note['error_pattern'], re.IGNORECASE | re.DOTALL)
|
|
150
|
+
if pattern.search(error_trace):
|
|
151
|
+
note_id = note['id']
|
|
152
|
+
note['conflicts'] = self.get_related_notes_by_type(note_id, 'conflict')
|
|
153
|
+
note['relations'] = self.get_related_notes_by_type(note_id, 'triggers')
|
|
154
|
+
|
|
155
|
+
# 매치된 에러이므로 최신 시간 갱신 (감쇠 방지)
|
|
156
|
+
with self._get_connection() as conn:
|
|
157
|
+
conn.execute("UPDATE incorrect_notes SET last_occurred_at = CURRENT_TIMESTAMP WHERE id = ?", (note_id,))
|
|
158
|
+
conn.commit()
|
|
159
|
+
|
|
160
|
+
matched_notes.append(note)
|
|
161
|
+
except re.error:
|
|
162
|
+
# 패턴 오류 시 단순 텍스트 서브스트링 비교 폴백
|
|
163
|
+
if note['error_pattern'].lower() in error_trace.lower():
|
|
164
|
+
note_id = note['id']
|
|
165
|
+
note['conflicts'] = self.get_related_notes_by_type(note_id, 'conflict')
|
|
166
|
+
note['relations'] = self.get_related_notes_by_type(note_id, 'triggers')
|
|
167
|
+
matched_notes.append(note)
|
|
168
|
+
|
|
169
|
+
# 가중치 우선 내림차순 정렬하되, target_model이 정확히 일치하는 것을 최우선 정렬 보너스 부여
|
|
170
|
+
def sort_key(x):
|
|
171
|
+
model_bonus = 1000 if x.get('target_model') == target_model and target_model != 'all' else 0
|
|
172
|
+
return x['occurrence_count'] + model_bonus
|
|
173
|
+
|
|
174
|
+
matched_notes.sort(key=sort_key, reverse=True)
|
|
175
|
+
return matched_notes
|
|
176
|
+
|
|
177
|
+
def apply_decay(self, days_threshold: int = 7) -> int:
|
|
178
|
+
"""Decrease occurrence_count of notes not triggered for days_threshold days."""
|
|
179
|
+
with self._get_connection() as conn:
|
|
180
|
+
# last_occurred_at이 days_threshold보다 과거인 항목들 가중치 차감 (최소 1)
|
|
181
|
+
cursor = conn.execute("""
|
|
182
|
+
UPDATE incorrect_notes
|
|
183
|
+
SET occurrence_count = MAX(1, CAST(occurrence_count * (1.0 - decay_factor) AS INTEGER))
|
|
184
|
+
WHERE last_occurred_at < datetime('now', ?) AND occurrence_count > 1
|
|
185
|
+
""", (f"-{days_threshold} days",))
|
|
186
|
+
conn.commit()
|
|
187
|
+
return cursor.rowcount
|
|
188
|
+
|
|
189
|
+
def update_verification(self, note_id: int, is_verified: bool) -> bool:
|
|
190
|
+
with self._get_connection() as conn:
|
|
191
|
+
cursor = conn.execute(
|
|
192
|
+
"UPDATE incorrect_notes SET is_verified = ? WHERE id = ?",
|
|
193
|
+
(1 if is_verified else 0, note_id)
|
|
194
|
+
)
|
|
195
|
+
conn.commit()
|
|
196
|
+
return cursor.rowcount > 0
|
|
197
|
+
|
|
198
|
+
def delete_note(self, note_id: int) -> bool:
|
|
199
|
+
with self._get_connection() as conn:
|
|
200
|
+
cursor = conn.execute("DELETE FROM incorrect_notes WHERE id = ?", (note_id,))
|
|
201
|
+
conn.commit()
|
|
202
|
+
return cursor.rowcount > 0
|
|
203
|
+
|
|
204
|
+
def list_all_notes(self) -> List[Dict[str, Any]]:
|
|
205
|
+
with self._get_connection() as conn:
|
|
206
|
+
cursor = conn.execute("SELECT * FROM incorrect_notes ORDER BY occurrence_count DESC")
|
|
207
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
208
|
+
|
|
209
|
+
def get_latest_note(self) -> Optional[Dict[str, Any]]:
|
|
210
|
+
with self._get_connection() as conn:
|
|
211
|
+
cursor = conn.execute("SELECT * FROM incorrect_notes ORDER BY id DESC LIMIT 1")
|
|
212
|
+
row = cursor.fetchone()
|
|
213
|
+
return dict(row) if row else None
|
|
214
|
+
|
|
215
|
+
def update_note(self, note_id: int, keyword: str = None, error_pattern: str = None, solution: str = None) -> bool:
|
|
216
|
+
with self._get_connection() as conn:
|
|
217
|
+
updates = []
|
|
218
|
+
params = []
|
|
219
|
+
if keyword is not None:
|
|
220
|
+
updates.append("keyword = ?")
|
|
221
|
+
params.append(keyword)
|
|
222
|
+
if error_pattern is not None:
|
|
223
|
+
updates.append("error_pattern = ?")
|
|
224
|
+
params.append(error_pattern)
|
|
225
|
+
if solution is not None:
|
|
226
|
+
updates.append("solution = ?")
|
|
227
|
+
params.append(solution)
|
|
228
|
+
if not updates:
|
|
229
|
+
return False
|
|
230
|
+
params.append(note_id)
|
|
231
|
+
cursor = conn.execute(f"UPDATE incorrect_notes SET {', '.join(updates)} WHERE id = ?", params)
|
|
232
|
+
conn.commit()
|
|
233
|
+
return cursor.rowcount > 0
|
|
234
|
+
|
|
235
|
+
# --- Relations and Conflict Handling ---
|
|
236
|
+
def add_relation(self, from_id: int, to_id: int, relation_type: str = 'conflict'):
|
|
237
|
+
with self._get_connection() as conn:
|
|
238
|
+
conn.execute(
|
|
239
|
+
"INSERT OR IGNORE INTO incorrect_note_relations (from_note_id, to_note_id, relation_type) VALUES (?, ?, ?)",
|
|
240
|
+
(from_id, to_id, relation_type)
|
|
241
|
+
)
|
|
242
|
+
if relation_type == 'conflict':
|
|
243
|
+
# 상충 관계는 양방향으로 등록
|
|
244
|
+
conn.execute(
|
|
245
|
+
"INSERT OR IGNORE INTO incorrect_note_relations (from_note_id, to_note_id, relation_type) VALUES (?, ?, ?)",
|
|
246
|
+
(to_id, from_id, relation_type)
|
|
247
|
+
)
|
|
248
|
+
conn.commit()
|
|
249
|
+
|
|
250
|
+
def get_related_notes_by_type(self, note_id: int, relation_type: str) -> List[Dict[str, Any]]:
|
|
251
|
+
with self._get_connection() as conn:
|
|
252
|
+
cursor = conn.execute("""
|
|
253
|
+
SELECT n.* FROM incorrect_notes n
|
|
254
|
+
JOIN incorrect_note_relations r ON n.id = r.to_note_id
|
|
255
|
+
WHERE r.from_note_id = ? AND r.relation_type = ?
|
|
256
|
+
""", (note_id, relation_type))
|
|
257
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
258
|
+
|
|
259
|
+
def merge_and_replace_notes(self, keep_id: int, delete_id: int, merged_solution: str, merged_keyword: str) -> bool:
|
|
260
|
+
"""Merge two conflicting notes into one, update the solution, and delete the other."""
|
|
261
|
+
with self._get_connection() as conn:
|
|
262
|
+
# 1. 유지할 노트의 솔루션, 키워드, 카운트 갱신
|
|
263
|
+
cursor = conn.execute("SELECT occurrence_count FROM incorrect_notes WHERE id = ?", (delete_id,))
|
|
264
|
+
del_row = cursor.fetchone()
|
|
265
|
+
del_count = del_row["occurrence_count"] if del_row else 1
|
|
266
|
+
|
|
267
|
+
conn.execute("""
|
|
268
|
+
UPDATE incorrect_notes
|
|
269
|
+
SET solution = ?, keyword = ?, occurrence_count = occurrence_count + ?, is_verified = 1, last_occurred_at = CURRENT_TIMESTAMP
|
|
270
|
+
WHERE id = ?
|
|
271
|
+
""", (merged_solution, merged_keyword, del_count, keep_id))
|
|
272
|
+
|
|
273
|
+
# 2. 다른 노트 삭제
|
|
274
|
+
conn.execute("DELETE FROM incorrect_notes WHERE id = ?", (delete_id,))
|
|
275
|
+
|
|
276
|
+
# 3. 구 관계 정보 클린업 및 업데이트
|
|
277
|
+
conn.execute("DELETE FROM incorrect_note_relations WHERE from_note_id = ? OR to_note_id = ?", (delete_id, delete_id))
|
|
278
|
+
conn.commit()
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --- agent_skills CRUD ---
|
|
283
|
+
def register_skill(self, name: str, cmd: str, desc: str, is_verified: bool = False) -> bool:
|
|
284
|
+
with self._get_connection() as conn:
|
|
285
|
+
try:
|
|
286
|
+
conn.execute(
|
|
287
|
+
"INSERT OR REPLACE INTO agent_skills (name, cmd, desc, is_verified) VALUES (?, ?, ?, ?)",
|
|
288
|
+
(name, cmd, desc, 1 if is_verified else 0)
|
|
289
|
+
)
|
|
290
|
+
conn.commit()
|
|
291
|
+
return True
|
|
292
|
+
except sqlite3.Error:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
def list_skills(self) -> List[Dict[str, Any]]:
|
|
296
|
+
with self._get_connection() as conn:
|
|
297
|
+
cursor = conn.execute("SELECT * FROM agent_skills")
|
|
298
|
+
return [dict(row) for row in cursor.fetchall()]
|
odab_note/server.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP
|
|
2
|
+
from odab_note.database import OdabNoteDB
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
# FastMCP 인스턴스 생성
|
|
7
|
+
mcp = FastMCP("OdabNote")
|
|
8
|
+
db = OdabNoteDB()
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
def query_notes(keywords: list[str]) -> str:
|
|
12
|
+
"""Query past mistakes and correct actions by keywords.
|
|
13
|
+
|
|
14
|
+
Use this at the beginning of a task to fetch relevant wrong-answer notes to avoid repeating past mistakes.
|
|
15
|
+
"""
|
|
16
|
+
notes = db.query_notes(keywords)
|
|
17
|
+
if not notes:
|
|
18
|
+
return "No relevant past mistakes found for keywords: " + ", ".join(keywords)
|
|
19
|
+
|
|
20
|
+
result = [f"Found {len(notes)} wrong-answer notes:"]
|
|
21
|
+
for idx, note in enumerate(notes, 1):
|
|
22
|
+
status = "Verified" if note['is_verified'] else "Draft (Not Verified)"
|
|
23
|
+
result.append(
|
|
24
|
+
f"{idx}. [{status}] (Weight: {note['occurrence_count']})\n"
|
|
25
|
+
f" - Target Keyword: {note['keyword']}\n"
|
|
26
|
+
f" - Error/Mistake Pattern: {note['error_pattern']}\n"
|
|
27
|
+
f" - Correct Solution: {note['solution']}"
|
|
28
|
+
)
|
|
29
|
+
return "\n\n".join(result)
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
def record_mistake(keyword: str, error_pattern: str, solution: str, target_model: str = "all") -> str:
|
|
33
|
+
"""Record a new mistake or error pattern with its correct solution.
|
|
34
|
+
|
|
35
|
+
Use this tool when a build error occurs, or when you receive negative feedback from the user.
|
|
36
|
+
target_model can be set to a specific model name (e.g. 'gemini-3.5-flash', 'claude-3.5-sonnet') to track model-specific mistakes.
|
|
37
|
+
"""
|
|
38
|
+
note_id = db.add_mistake(keyword, error_pattern, solution, target_model=target_model)
|
|
39
|
+
return f"Successfully recorded mistake (ID: {note_id}, Model: {target_model}). Status is set to Draft. Ask the user or CLI to verify it."
|
|
40
|
+
|
|
41
|
+
@mcp.tool()
|
|
42
|
+
def propose_conflict_resolution(note_id_a: int, note_id_b: int, proposed_solution_c: str) -> str:
|
|
43
|
+
"""Propose a resolution for two conflicting wrong-answer notes A and B.
|
|
44
|
+
|
|
45
|
+
This generates an option block for the user to select A, B, or a merged solution C.
|
|
46
|
+
"""
|
|
47
|
+
# A와 B의 관계를 conflict로 기록
|
|
48
|
+
db.add_relation(note_id_a, note_id_b, 'conflict')
|
|
49
|
+
|
|
50
|
+
notes = db.list_all_notes()
|
|
51
|
+
note_a = next((n for n in notes if n['id'] == note_id_a), None)
|
|
52
|
+
note_b = next((n for n in notes if n['id'] == note_id_b), None)
|
|
53
|
+
|
|
54
|
+
if not note_a or not note_b:
|
|
55
|
+
return "Failed to propose resolution: One or both notes not found."
|
|
56
|
+
|
|
57
|
+
report = (
|
|
58
|
+
f"[Odab-Note Conflict Alert 🚨]\n"
|
|
59
|
+
f"A conflict has been detected between note {note_id_a} and note {note_id_b}.\n\n"
|
|
60
|
+
f"- Note A (Existing/Failed): [ID: {note_id_a}] Keyword: {note_a['keyword']}\n"
|
|
61
|
+
f" * Error: {note_a['error_pattern']}\n"
|
|
62
|
+
f" * Solution: {note_a['solution']}\n\n"
|
|
63
|
+
f"- Note B (New/Recent): [ID: {note_id_b}] Keyword: {note_b['keyword']}\n"
|
|
64
|
+
f" * Error: {note_b['error_pattern']}\n"
|
|
65
|
+
f" * Solution: {note_b['solution']}\n\n"
|
|
66
|
+
f"- Proposed Merged Option C:\n"
|
|
67
|
+
f" * Solution: {proposed_solution_c}\n\n"
|
|
68
|
+
f"Please run the CLI to resolve this conflict:\n"
|
|
69
|
+
f" `odab-note resolve {note_id_a} {note_id_b} --solution-c \"{proposed_solution_c}\"`\n"
|
|
70
|
+
f"Or choose to reject B and keep A by running:\n"
|
|
71
|
+
f" `odab-note delete {note_id_b}`"
|
|
72
|
+
)
|
|
73
|
+
return report
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
def resolve_conflict_merge(keep_id: int, delete_id: int, merged_solution: str, merged_keyword: str) -> str:
|
|
77
|
+
"""Resolve a conflict by merging note `delete_id` into `keep_id` and updating it with `merged_solution`."""
|
|
78
|
+
success = db.merge_and_replace_notes(keep_id, delete_id, merged_solution, merged_keyword)
|
|
79
|
+
if success:
|
|
80
|
+
return f"Successfully merged note {delete_id} into note {keep_id} with new solution."
|
|
81
|
+
else:
|
|
82
|
+
return "Failed to resolve conflict."
|
|
83
|
+
|
|
84
|
+
@mcp.tool()
|
|
85
|
+
def register_skill(name: str, cmd: str, desc: str) -> str:
|
|
86
|
+
"""Register a verified, reusable skill (workflow command).
|
|
87
|
+
|
|
88
|
+
Use this to save successful execution commands or steps for future runs.
|
|
89
|
+
"""
|
|
90
|
+
success = db.register_skill(name, cmd, desc)
|
|
91
|
+
if success:
|
|
92
|
+
return f"Successfully registered skill '{name}'."
|
|
93
|
+
else:
|
|
94
|
+
return f"Failed to register skill '{name}'."
|
|
95
|
+
|
|
96
|
+
@mcp.tool()
|
|
97
|
+
def match_error_trace(error_trace: str, target_model: str = "all", only_verified: bool = False) -> str:
|
|
98
|
+
"""Match a stack trace or compilation error against database regexes.
|
|
99
|
+
|
|
100
|
+
Use this tool when an execution or compilation error occurs during coding to find a correction.
|
|
101
|
+
"""
|
|
102
|
+
notes = db.match_error_trace(error_trace, target_model=target_model, only_verified=only_verified)
|
|
103
|
+
if not notes:
|
|
104
|
+
return "No matching past error patterns found."
|
|
105
|
+
|
|
106
|
+
result = [f"Found {len(notes)} matching wrong-answer notes:"]
|
|
107
|
+
for idx, note in enumerate(notes, 1):
|
|
108
|
+
status = "Verified" if note['is_verified'] else "Draft"
|
|
109
|
+
result.append(
|
|
110
|
+
f"{idx}. [{status}] (Weight: {note['occurrence_count']}, Model: {note.get('target_model', 'all')})\n"
|
|
111
|
+
f" - Target Keyword: {note['keyword']}\n"
|
|
112
|
+
f" - Match Pattern: {note['error_pattern']}\n"
|
|
113
|
+
f" - Correct Solution: {note['solution']}"
|
|
114
|
+
)
|
|
115
|
+
return "\n\n".join(result)
|
|
116
|
+
|
|
117
|
+
@mcp.tool()
|
|
118
|
+
def auto_record(what_went_wrong: str, what_fixed_it: str, target_model: str = "all") -> str:
|
|
119
|
+
"""Quick-record a mistake from plain language. This is the 'odab pull' trigger.
|
|
120
|
+
|
|
121
|
+
When the user says '오답 넣어' or 'odab pull':
|
|
122
|
+
1. Analyze your recent conversation to identify the error and the fix.
|
|
123
|
+
2. Call this tool with a plain-language description. No regex needed.
|
|
124
|
+
3. The keyword is auto-generated from the description.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
what_went_wrong: Plain description of the mistake (e.g. 'used LKS venv instead of local venv')
|
|
128
|
+
what_fixed_it: Plain description of the fix (e.g. 'created dedicated .venv inside project dir')
|
|
129
|
+
target_model: Which model made this mistake (e.g. 'gemini-3.5-flash', 'claude-3.5-sonnet', 'all')
|
|
130
|
+
"""
|
|
131
|
+
# Auto-generate keyword from description
|
|
132
|
+
clean = re.sub(r'[^\w\s]', '', what_went_wrong)
|
|
133
|
+
words = [w.capitalize() for w in clean.split() if len(w) > 2][:4]
|
|
134
|
+
keyword = "_".join(words) if words else "Unknown_Error"
|
|
135
|
+
|
|
136
|
+
note_id = db.add_mistake(keyword, what_went_wrong, what_fixed_it, target_model=target_model)
|
|
137
|
+
return (
|
|
138
|
+
f"✅ Recorded (ID: {note_id}, Model: {target_model})\n"
|
|
139
|
+
f" Keyword: {keyword}\n"
|
|
140
|
+
f" Mistake: {what_went_wrong}\n"
|
|
141
|
+
f" Fix: {what_fixed_it}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@mcp.tool()
|
|
145
|
+
def revise_last(correction: str) -> str:
|
|
146
|
+
"""Revise the most recently recorded note based on user feedback.
|
|
147
|
+
|
|
148
|
+
Trigger: '오답 수정' or 'odab fix'
|
|
149
|
+
|
|
150
|
+
The user may say something like:
|
|
151
|
+
'오답 수정 파일명을 그렇게 바꾸지 말라는거야 다른거랑 맞춰서 넣어야지'
|
|
152
|
+
|
|
153
|
+
You must:
|
|
154
|
+
1. Get the latest note from the database.
|
|
155
|
+
2. Re-interpret the user's feedback to update the mistake description and/or fix.
|
|
156
|
+
3. Call this tool with the corrected content.
|
|
157
|
+
4. REPORT what changed to the user.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
correction: The corrected content. Format as 'mistake: ... | fix: ...' or just the part to change.
|
|
161
|
+
"""
|
|
162
|
+
latest = db.get_latest_note()
|
|
163
|
+
if not latest:
|
|
164
|
+
return "❌ No notes found to revise."
|
|
165
|
+
|
|
166
|
+
note_id = latest['id']
|
|
167
|
+
|
|
168
|
+
# Parse correction — if it contains 'mistake:' and 'fix:', split them
|
|
169
|
+
if '|' in correction:
|
|
170
|
+
parts = correction.split('|', 1)
|
|
171
|
+
new_mistake = parts[0].strip()
|
|
172
|
+
new_fix = parts[1].strip()
|
|
173
|
+
db.update_note(note_id, error_pattern=new_mistake, solution=new_fix)
|
|
174
|
+
# Regenerate keyword
|
|
175
|
+
clean = re.sub(r'[^\w\s]', '', new_mistake)
|
|
176
|
+
words = [w.capitalize() for w in clean.split() if len(w) > 2][:4]
|
|
177
|
+
keyword = "_".join(words) if words else latest['keyword']
|
|
178
|
+
db.update_note(note_id, keyword=keyword)
|
|
179
|
+
else:
|
|
180
|
+
# Update the solution/fix part only
|
|
181
|
+
db.update_note(note_id, solution=correction)
|
|
182
|
+
keyword = latest['keyword']
|
|
183
|
+
new_mistake = latest['error_pattern']
|
|
184
|
+
new_fix = correction
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
f"📝 Revised note (ID: {note_id})\n"
|
|
188
|
+
f" Keyword: {keyword}\n"
|
|
189
|
+
f" Mistake: {new_mistake}\n"
|
|
190
|
+
f" Fix: {new_fix}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@mcp.tool()
|
|
194
|
+
def delete_last() -> str:
|
|
195
|
+
"""Delete the most recently recorded note.
|
|
196
|
+
|
|
197
|
+
Trigger: '오답 삭제' or 'odab del'
|
|
198
|
+
|
|
199
|
+
You must REPORT what was deleted to the user.
|
|
200
|
+
"""
|
|
201
|
+
latest = db.get_latest_note()
|
|
202
|
+
if not latest:
|
|
203
|
+
return "❌ No notes found to delete."
|
|
204
|
+
|
|
205
|
+
note_id = latest['id']
|
|
206
|
+
keyword = latest['keyword']
|
|
207
|
+
db.delete_note(note_id)
|
|
208
|
+
return (
|
|
209
|
+
f"🗑️ Deleted note (ID: {note_id})\n"
|
|
210
|
+
f" Keyword: {keyword}\n"
|
|
211
|
+
f" Was: {latest['error_pattern']}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def run():
|
|
215
|
+
mcp.run()
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
run()
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odab-note
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Wrong-answer vaccine system for AI agents — learns error patterns and prevents repeated mistakes via MCP
|
|
5
|
+
Author-email: Logan Lee <logan@logans.company>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: click>=8.0.0
|
|
8
|
+
Requires-Dist: mcp>=0.1.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# OdabNote — Wrong-Answer Vaccine System for AI Agents
|
|
12
|
+
|
|
13
|
+
[](https://github.com/sponsors/hacker3699-max)
|
|
14
|
+
[](LICENSE)
|
|
15
|
+
|
|
16
|
+
> **Teach AI agents to never repeat the same coding mistake twice.**
|
|
17
|
+
|
|
18
|
+
OdabNote is a local MCP (Model Context Protocol) server that captures error patterns, stores verified solutions, and automatically matches future errors against its database — acting as an immune system for AI coding agents.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Why OdabNote?
|
|
23
|
+
|
|
24
|
+
AI coding agents (Claude Code, Codex, Cursor, Gemini, etc.) repeatedly make the same mistakes across sessions. They forget what went wrong last time. OdabNote solves this by:
|
|
25
|
+
|
|
26
|
+
1. **Recording** error patterns with their verified fixes
|
|
27
|
+
2. **Matching** new errors against the database in real-time
|
|
28
|
+
3. **Preventing** the same mistake from happening again
|
|
29
|
+
4. **Tracking** which AI models make which mistakes (model-specific blacklists)
|
|
30
|
+
5. **Decaying** outdated patterns so the knowledge stays fresh
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### Prerequisites
|
|
37
|
+
|
|
38
|
+
- Python 3.10+
|
|
39
|
+
- pip
|
|
40
|
+
|
|
41
|
+
### Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/hacker3699-max/OdabNote.git
|
|
45
|
+
cd OdabNote
|
|
46
|
+
python3 -m venv .venv
|
|
47
|
+
.venv/bin/pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Verify Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
.venv/bin/odab --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Expected output:
|
|
57
|
+
```
|
|
58
|
+
Usage: odab [OPTIONS] COMMAND [ARGS]...
|
|
59
|
+
|
|
60
|
+
OdabNote CLI - Manage wrong-answer notes for AI Agents.
|
|
61
|
+
|
|
62
|
+
Commands:
|
|
63
|
+
add Add a new wrong-answer note manually (auto-verified).
|
|
64
|
+
approve Approve a wrong-answer note (Veto Pass).
|
|
65
|
+
decay Manually apply time-decay to decrease weights of obsolete notes.
|
|
66
|
+
delete Delete a wrong-answer note.
|
|
67
|
+
graph Visualize relations and conflicts of wrong-answer notes.
|
|
68
|
+
link Link two notes with relation (triggers or conflict).
|
|
69
|
+
list List all wrong-answer notes.
|
|
70
|
+
resolve Interactively resolve conflict between Note A and Note B.
|
|
71
|
+
run-server Run the OdabNote MCP server.
|
|
72
|
+
show Show details of a specific wrong-answer note.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Usage Guide
|
|
78
|
+
|
|
79
|
+
### How It Works (Natural Language)
|
|
80
|
+
|
|
81
|
+
You don't need to type CLI commands. Just talk to your AI agent in natural language.
|
|
82
|
+
|
|
83
|
+
**Step 1 — Agent makes a mistake. You say: `오답 넣어` or `odab pull`**
|
|
84
|
+
|
|
85
|
+
> You asked the agent to modify `config.yaml`, but it created a new `config_new.yaml` instead.
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
You: Why did you create a new file? I said modify the existing one. odab pull
|
|
89
|
+
|
|
90
|
+
Agent: ✅ Recorded (ID: 13, Model: claude-opus-4.6)
|
|
91
|
+
Keyword: Created_New_File_Instead_Modifying
|
|
92
|
+
Mistake: User asked to modify existing file but agent created a new file instead
|
|
93
|
+
Fix: When user says 'modify', always edit the existing file in place.
|
|
94
|
+
Never create a new file unless explicitly asked.
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Step 2 — Not happy with the note? Say: `오답 수정` or `odab fix`**
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
You: odab fix I meant you should always back up config files before editing them
|
|
101
|
+
|
|
102
|
+
Agent: 📝 Revised note (ID: 13)
|
|
103
|
+
Keyword: Config_File_Backup_First
|
|
104
|
+
Mistake: Modified config file without creating a backup first
|
|
105
|
+
Fix: Always create a backup copy before modifying any config file
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Step 3 — Want to remove it entirely? Say: `오답 삭제` or `odab del`**
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
You: odab del
|
|
112
|
+
|
|
113
|
+
Agent: 🗑️ Deleted note (ID: 13)
|
|
114
|
+
Keyword: Config_File_Backup_First
|
|
115
|
+
Was: Modified config file without creating a backup first
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**That's it.** Three phrases to remember:
|
|
119
|
+
|
|
120
|
+
| Korean | English | What it does |
|
|
121
|
+
|--------|---------|-------------|
|
|
122
|
+
| `오답 넣어` | `odab pull` | Record the last mistake |
|
|
123
|
+
| `오답 수정` | `odab fix` | Revise the note |
|
|
124
|
+
| `오답 삭제` | `odab del` | Delete the note |
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### CLI Reference
|
|
129
|
+
|
|
130
|
+
#### 1. Record a Mistake
|
|
131
|
+
|
|
132
|
+
When you encounter an error and fix it, record it:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
odab add \
|
|
136
|
+
-k "ZeroDivisionPrevention" \
|
|
137
|
+
-e "ZeroDivisionError: division by zero" \
|
|
138
|
+
-f "Check if denominator == 0 before dividing and return a safe default value" \
|
|
139
|
+
-m "all"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Flag | Description |
|
|
143
|
+
|------|-------------|
|
|
144
|
+
| `-k` | Keyword name for this mistake (unique identifier) |
|
|
145
|
+
| `-e` | Error pattern (supports regex) |
|
|
146
|
+
| `-f` | The correct fix / solution |
|
|
147
|
+
| `-m` | Target model (`all`, `gemini-3.5-flash`, `claude-3.5-sonnet`, etc.) |
|
|
148
|
+
|
|
149
|
+
### 2. List All Mistakes
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
odab list
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
ID | Keyword | Model | Count | Verified | Error Pattern
|
|
157
|
+
-----------------------------------------------------------------------------------------------
|
|
158
|
+
1 | ZeroDivisionPrev... | all | 1 | Yes | ZeroDivisionError: division by...
|
|
159
|
+
2 | Surface_Only_Fix | gemini-3.5-flash | 1 | Yes | Fixed surface only, left old refs...
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### 3. View Details
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
odab show 1
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 4. Visualize Relations
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
odab graph
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
[ Odab-Note Dependency & Conflict Graph 🕸️ ]
|
|
176
|
+
|
|
177
|
+
● [ID: 1] ZeroDivisionPrevention (Count: 1, Verified: ✓)
|
|
178
|
+
└── (No active relations)
|
|
179
|
+
|
|
180
|
+
● [ID: 2] Surface_Only_Fix (Count: 1, Verified: ✓)
|
|
181
|
+
└── 🔗 [Triggers] -> [ID: 3] No_Full_Audit_Before_Done
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### 5. Link Related Mistakes
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
odab link 2 3 --type triggers
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 6. Resolve Conflicts
|
|
191
|
+
|
|
192
|
+
When two notes contradict each other:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
odab resolve 1 2 --solution-c "Merged solution that combines both approaches"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 7. Apply Time Decay
|
|
199
|
+
|
|
200
|
+
Decrease weight of notes not triggered in N days:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
odab decay --days 30
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## MCP Server Integration
|
|
209
|
+
|
|
210
|
+
### For Gemini (Antigravity)
|
|
211
|
+
|
|
212
|
+
Add to `~/.gemini/antigravity/mcp_config.json`:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"odab-note": {
|
|
217
|
+
"command": "/path/to/OdabNote/.venv/bin/python",
|
|
218
|
+
"args": ["-m", "odab_note.server"],
|
|
219
|
+
"env": {
|
|
220
|
+
"PYTHONPATH": "/path/to/OdabNote/src"
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### For Claude Code / Cursor / Cline
|
|
227
|
+
|
|
228
|
+
Add to your MCP settings (e.g. `.cursor/mcp.json` or `claude_desktop_config.json`):
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"mcpServers": {
|
|
233
|
+
"odab-note": {
|
|
234
|
+
"command": "/path/to/OdabNote/.venv/bin/python",
|
|
235
|
+
"args": ["-m", "odab_note.server"],
|
|
236
|
+
"env": {
|
|
237
|
+
"PYTHONPATH": "/path/to/OdabNote/src"
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Available MCP Tools
|
|
245
|
+
|
|
246
|
+
| Tool | Description |
|
|
247
|
+
|------|-------------|
|
|
248
|
+
| `query_notes(keywords)` | Search past mistakes by keyword before starting work |
|
|
249
|
+
| `match_error_trace(error_trace, target_model)` | Match a stack trace against the database to find a known fix |
|
|
250
|
+
| `record_mistake(keyword, error_pattern, solution, target_model)` | Record a new mistake with its solution |
|
|
251
|
+
| `propose_conflict_resolution(note_id_a, note_id_b, proposed_solution_c)` | Propose resolution for conflicting notes |
|
|
252
|
+
| `resolve_conflict_merge(keep_id, delete_id, merged_solution, merged_keyword)` | Execute a conflict merge |
|
|
253
|
+
| `register_skill(name, cmd, desc)` | Save a verified workflow command for reuse |
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Testing
|
|
258
|
+
|
|
259
|
+
### Run the E2E Verification Script
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
cd OdabNote
|
|
263
|
+
.venv/bin/python tests/test_mcp.py
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Expected output:
|
|
267
|
+
```
|
|
268
|
+
🔍 [TEST 1] Query Notes with keyword 'SMC'...
|
|
269
|
+
Result:
|
|
270
|
+
Found 2 wrong-answer notes:
|
|
271
|
+
...
|
|
272
|
+
|
|
273
|
+
🔍 [TEST 2] Match Error Trace for SMC Timeout...
|
|
274
|
+
Result:
|
|
275
|
+
Found 1 matching wrong-answer notes:
|
|
276
|
+
...
|
|
277
|
+
|
|
278
|
+
🔍 [TEST 3] Match ZeroDivisionError Trace...
|
|
279
|
+
Result:
|
|
280
|
+
Found 1 matching wrong-answer notes:
|
|
281
|
+
- Target Keyword: ZeroDivisionPrevention
|
|
282
|
+
- Correct Solution: Check if denominator == 0 before dividing...
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Manual End-to-End Test
|
|
286
|
+
|
|
287
|
+
1. **Create a bug:**
|
|
288
|
+
```bash
|
|
289
|
+
echo 'print(1/0)' > /tmp/bad.py
|
|
290
|
+
python3 /tmp/bad.py # ZeroDivisionError
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
2. **Record it:**
|
|
294
|
+
```bash
|
|
295
|
+
odab add -k "DivByZero" -e "ZeroDivisionError" -f "Guard with if x != 0" -m "all"
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
3. **Verify it matches:**
|
|
299
|
+
```bash
|
|
300
|
+
.venv/bin/python -c "
|
|
301
|
+
from odab_note.server import match_error_trace
|
|
302
|
+
print(match_error_trace('ZeroDivisionError: division by zero'))
|
|
303
|
+
"
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
4. **Confirm the vaccine is prescribed** — the output should show the fix you recorded.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Project Structure
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
OdabNote/
|
|
314
|
+
├── src/odab_note/
|
|
315
|
+
│ ├── __init__.py
|
|
316
|
+
│ ├── server.py # FastMCP server with 6 tools
|
|
317
|
+
│ ├── database.py # SQLite DB with regex matching, decay, relations
|
|
318
|
+
│ └── cli.py # Click CLI with 10 subcommands
|
|
319
|
+
├── tests/
|
|
320
|
+
│ ├── test_mcp.py # E2E MCP tool verification
|
|
321
|
+
│ └── buggy_script.py # Intentional bug for testing
|
|
322
|
+
├── docs/ # PRD, philosophy, tier matrix
|
|
323
|
+
├── SKILL.md # Agent instruction file (for Claude Code, Codex, etc.)
|
|
324
|
+
├── pyproject.toml
|
|
325
|
+
└── README.md
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Database Location
|
|
329
|
+
|
|
330
|
+
The SQLite database is stored at:
|
|
331
|
+
```
|
|
332
|
+
~/.gemini/antigravity/odab_note.db
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## License
|
|
338
|
+
|
|
339
|
+
© Logan's Company. All rights reserved.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
odab_note/__init__.py,sha256=dFzoGs9TeaV0RVL-OzaI_2-afx_jmBvVibV2zcfIJrw,20
|
|
2
|
+
odab_note/cli.py,sha256=EWBaILjS_sYOVU_I6Rv2kRbGK1XkFDWctSMZUoYIIVI,8070
|
|
3
|
+
odab_note/database.py,sha256=pePQMvivnbAibxOwa1jjIsXAiW-aQPssqEnNo7m1oYY,14336
|
|
4
|
+
odab_note/server.py,sha256=7_tAVLF9zz9VxzidWoL-7BnaZGTCPzo6bxec7zHAYPc,8837
|
|
5
|
+
odab_note-0.1.0.dist-info/METADATA,sha256=X87YEf3jd-uMjnzBa2EkVecdwyiXNQjozUTmQctQeOY,9077
|
|
6
|
+
odab_note-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
odab_note-0.1.0.dist-info/entry_points.txt,sha256=2Nm0LsEpYxilaf0cZcoHmObefdbfdth2Az5OROl3UtU,77
|
|
8
|
+
odab_note-0.1.0.dist-info/RECORD,,
|