Clean up nested duplicate: v2/v2/engine/memory.py
Browse files- v2/v2/engine/memory.py +0 -410
v2/v2/engine/memory.py
DELETED
|
@@ -1,410 +0,0 @@
|
|
| 1 |
-
"""Rivet Memory — persistent, per-user memory over SQLite.
|
| 2 |
-
|
| 3 |
-
Same shape as Ember's semantic memory (flat records + relevance recall)
|
| 4 |
-
but scoped for a code assistant and dependency-free: instead of an
|
| 5 |
-
embedding model, recall scores stored memories against the query with
|
| 6 |
-
TF-IDF-style keyword overlap, weighted by recency and how often a memory
|
| 7 |
-
has recurred. Good enough for "what has this user hit before" — not a
|
| 8 |
-
vector store.
|
| 9 |
-
|
| 10 |
-
Rivet decides what to remember (calls remember_*). Memory decides what
|
| 11 |
-
to surface (recall / session_brief ranks and returns it). Memory never
|
| 12 |
-
inspects code or talks to the model — it only stores and ranks text
|
| 13 |
-
Rivet hands it.
|
| 14 |
-
|
| 15 |
-
One SQLite file per deployment. Each user gets their own table
|
| 16 |
-
(memories_<user_id>) so one user's history never leaks into another's
|
| 17 |
-
recall results.
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
import argparse
|
| 21 |
-
import hashlib
|
| 22 |
-
import math
|
| 23 |
-
import os
|
| 24 |
-
import re
|
| 25 |
-
import sqlite3
|
| 26 |
-
from collections import Counter
|
| 27 |
-
from dataclasses import dataclass
|
| 28 |
-
from datetime import datetime, timezone
|
| 29 |
-
from pathlib import Path
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
DEFAULT_DB_PATH = Path(
|
| 33 |
-
os.environ.get("RIVET_MEMORY_DB")
|
| 34 |
-
or (Path(__file__).resolve().parent.parent / "data" / "rivet_memory.db")
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
KIND_FILE = "file" # a file the user works on
|
| 38 |
-
KIND_QA = "qa" # a question asked + the answer given
|
| 39 |
-
KIND_PATTERN = "pattern" # a recurring behavior (e.g. keeps proposing destructive migrations)
|
| 40 |
-
KIND_GATE = "gate_correction" # a discipline-gate flag that fired on this user's work
|
| 41 |
-
KIND_CONTEXT = "context" # single-value facts: current_branch, recent_pr, ...
|
| 42 |
-
|
| 43 |
-
_STOPWORDS = {
|
| 44 |
-
"the", "a", "an", "is", "are", "was", "were", "to", "of", "in", "on",
|
| 45 |
-
"for", "and", "or", "it", "this", "that", "with", "at", "as", "be",
|
| 46 |
-
"by", "from", "how", "what", "why", "do", "does", "did", "i", "you",
|
| 47 |
-
"we", "my", "me", "can", "will", "should", "please",
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
# Vocabulary from DESIGN.md's architecture map — matched against file
|
| 51 |
-
# paths so file memories carry cross-cutting tags ("auth", "migration")
|
| 52 |
-
# even when the path itself doesn't spell them out.
|
| 53 |
-
_DOMAIN_TAGS = {
|
| 54 |
-
"auth": ("auth", "jwt", "session", "middleware"),
|
| 55 |
-
"migration": ("migration", "migrations", "schema"),
|
| 56 |
-
"webhook": ("webhook", "webhooks", "stripe"),
|
| 57 |
-
"socket": ("socket", "sockets", "realtime", "pubsub"),
|
| 58 |
-
"store": ("store", "stores", "zustand"),
|
| 59 |
-
"route": ("route", "routes", "api"),
|
| 60 |
-
"job": ("job", "jobs", "pg-boss", "queue"),
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def _now() -> str:
|
| 65 |
-
return datetime.now(timezone.utc).isoformat()
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def _sanitize_user_id(user_id: str) -> str:
|
| 69 |
-
slug = re.sub(r"[^a-zA-Z0-9_]", "_", user_id.strip())
|
| 70 |
-
if not slug:
|
| 71 |
-
raise ValueError("user_id must contain at least one alphanumeric character")
|
| 72 |
-
return slug
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def _tokenize(text: str) -> list:
|
| 76 |
-
words = re.findall(r"[a-z0-9_./-]+", text.lower())
|
| 77 |
-
return [w for w in words if w not in _STOPWORDS and len(w) > 1]
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def _content_key(text: str) -> str:
|
| 81 |
-
return hashlib.sha1(text.strip().lower().encode("utf-8")).hexdigest()[:16]
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def _path_tags(file_path: str) -> list:
|
| 85 |
-
lowered = file_path.lower()
|
| 86 |
-
tags = set()
|
| 87 |
-
for tag, needles in _DOMAIN_TAGS.items():
|
| 88 |
-
if any(n in lowered for n in needles):
|
| 89 |
-
tags.add(tag)
|
| 90 |
-
return sorted(tags)
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def _age_days(iso_timestamp: str, now: datetime) -> float:
|
| 94 |
-
try:
|
| 95 |
-
ts = datetime.fromisoformat(iso_timestamp)
|
| 96 |
-
except ValueError:
|
| 97 |
-
return 0.0
|
| 98 |
-
if ts.tzinfo is None:
|
| 99 |
-
ts = ts.replace(tzinfo=timezone.utc)
|
| 100 |
-
return max(0.0, (now - ts).total_seconds() / 86400.0)
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
@dataclass
|
| 104 |
-
class MemoryEntry:
|
| 105 |
-
id: int
|
| 106 |
-
kind: str
|
| 107 |
-
key: str
|
| 108 |
-
content: str
|
| 109 |
-
tags: str
|
| 110 |
-
weight: float
|
| 111 |
-
hit_count: int
|
| 112 |
-
created_at: str
|
| 113 |
-
updated_at: str
|
| 114 |
-
score: float = 0.0
|
| 115 |
-
|
| 116 |
-
@property
|
| 117 |
-
def tag_list(self) -> list:
|
| 118 |
-
return [t for t in self.tags.split(",") if t] if self.tags else []
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def _to_entry(row: sqlite3.Row, score: float = 0.0) -> MemoryEntry:
|
| 122 |
-
return MemoryEntry(
|
| 123 |
-
id=row["id"], kind=row["kind"], key=row["key"], content=row["content"],
|
| 124 |
-
tags=row["tags"] or "", weight=row["weight"], hit_count=row["hit_count"],
|
| 125 |
-
created_at=row["created_at"], updated_at=row["updated_at"], score=score,
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
class MemoryStore:
|
| 130 |
-
"""Per-user persistent memory backed by SQLite. One table per user."""
|
| 131 |
-
|
| 132 |
-
def __init__(self, db_path=DEFAULT_DB_PATH):
|
| 133 |
-
self.db_path = Path(db_path)
|
| 134 |
-
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 135 |
-
self._conn = sqlite3.connect(str(self.db_path))
|
| 136 |
-
self._conn.row_factory = sqlite3.Row
|
| 137 |
-
self._conn.execute("PRAGMA journal_mode=WAL")
|
| 138 |
-
self._conn.execute("""
|
| 139 |
-
CREATE TABLE IF NOT EXISTS users (
|
| 140 |
-
user_id TEXT PRIMARY KEY,
|
| 141 |
-
table_name TEXT NOT NULL,
|
| 142 |
-
first_seen TEXT NOT NULL,
|
| 143 |
-
last_seen TEXT NOT NULL
|
| 144 |
-
)
|
| 145 |
-
""")
|
| 146 |
-
self._conn.commit()
|
| 147 |
-
|
| 148 |
-
def close(self):
|
| 149 |
-
self._conn.close()
|
| 150 |
-
|
| 151 |
-
def __enter__(self):
|
| 152 |
-
return self
|
| 153 |
-
|
| 154 |
-
def __exit__(self, *exc):
|
| 155 |
-
self.close()
|
| 156 |
-
|
| 157 |
-
# -- per-user table management ------------------------------------
|
| 158 |
-
|
| 159 |
-
def _table_for(self, user_id: str) -> str:
|
| 160 |
-
table = f"memories_{_sanitize_user_id(user_id)}"
|
| 161 |
-
now = _now()
|
| 162 |
-
row = self._conn.execute(
|
| 163 |
-
"SELECT table_name FROM users WHERE user_id = ?", (user_id,)
|
| 164 |
-
).fetchone()
|
| 165 |
-
if row is None:
|
| 166 |
-
self._conn.execute(
|
| 167 |
-
"INSERT INTO users (user_id, table_name, first_seen, last_seen) VALUES (?, ?, ?, ?)",
|
| 168 |
-
(user_id, table, now, now),
|
| 169 |
-
)
|
| 170 |
-
self._conn.execute(f"""
|
| 171 |
-
CREATE TABLE IF NOT EXISTS "{table}" (
|
| 172 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 173 |
-
kind TEXT NOT NULL,
|
| 174 |
-
key TEXT NOT NULL,
|
| 175 |
-
content TEXT NOT NULL,
|
| 176 |
-
tags TEXT DEFAULT '',
|
| 177 |
-
weight REAL NOT NULL DEFAULT 1.0,
|
| 178 |
-
hit_count INTEGER NOT NULL DEFAULT 1,
|
| 179 |
-
created_at TEXT NOT NULL,
|
| 180 |
-
updated_at TEXT NOT NULL,
|
| 181 |
-
UNIQUE(kind, key)
|
| 182 |
-
)
|
| 183 |
-
""")
|
| 184 |
-
self._conn.commit()
|
| 185 |
-
else:
|
| 186 |
-
self._conn.execute("UPDATE users SET last_seen = ? WHERE user_id = ?", (now, user_id))
|
| 187 |
-
self._conn.commit()
|
| 188 |
-
table = row["table_name"]
|
| 189 |
-
return table
|
| 190 |
-
|
| 191 |
-
# -- writing (Rivet decides what to remember) ----------------------
|
| 192 |
-
|
| 193 |
-
def remember(self, user_id: str, kind: str, content: str, key: str = None,
|
| 194 |
-
tags=None, weight: float = 1.0) -> None:
|
| 195 |
-
"""Store or reinforce a memory. Same (kind, key) upserts: content is
|
| 196 |
-
replaced with the latest version, weight and hit_count accumulate so
|
| 197 |
-
recurring evidence outranks one-off mentions."""
|
| 198 |
-
table = self._table_for(user_id)
|
| 199 |
-
key = key or _content_key(content)
|
| 200 |
-
tag_str = ",".join(sorted(set(tags))) if tags else ""
|
| 201 |
-
now = _now()
|
| 202 |
-
self._conn.execute(f"""
|
| 203 |
-
INSERT INTO "{table}" (kind, key, content, tags, weight, hit_count, created_at, updated_at)
|
| 204 |
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
| 205 |
-
ON CONFLICT(kind, key) DO UPDATE SET
|
| 206 |
-
content = excluded.content,
|
| 207 |
-
tags = CASE WHEN excluded.tags != '' THEN excluded.tags ELSE tags END,
|
| 208 |
-
weight = weight + excluded.weight,
|
| 209 |
-
hit_count = hit_count + 1,
|
| 210 |
-
updated_at = excluded.updated_at
|
| 211 |
-
""", (kind, key, content, tag_str, weight, now, now))
|
| 212 |
-
self._conn.commit()
|
| 213 |
-
|
| 214 |
-
def remember_file(self, user_id: str, file_path: str, note: str = "") -> None:
|
| 215 |
-
"""A file the user touched, optionally with what they were doing there."""
|
| 216 |
-
self.remember(user_id, KIND_FILE, note or file_path, key=file_path,
|
| 217 |
-
tags=_path_tags(file_path), weight=1.0)
|
| 218 |
-
|
| 219 |
-
def remember_qa(self, user_id: str, intent: str, outcome: str, tags=None) -> None:
|
| 220 |
-
"""A question + answer, compressed to intent/outcome (not full transcript)."""
|
| 221 |
-
content = f"Q: {intent.strip()}\nA: {outcome.strip()}"
|
| 222 |
-
self.remember(user_id, KIND_QA, content, key=_content_key(intent), tags=tags, weight=1.0)
|
| 223 |
-
|
| 224 |
-
def remember_pattern(self, user_id: str, pattern: str, detail: str = "", tags=None) -> None:
|
| 225 |
-
"""A recurring behavior worth flagging preemptively next time it shows up."""
|
| 226 |
-
self.remember(user_id, KIND_PATTERN, detail or pattern, key=pattern,
|
| 227 |
-
tags=tags, weight=2.0)
|
| 228 |
-
|
| 229 |
-
def remember_gate_correction(self, user_id: str, rule: str, message: str,
|
| 230 |
-
severity: str = "WARN") -> None:
|
| 231 |
-
"""A discipline-gate flag that fired against this user's suggestion."""
|
| 232 |
-
self.remember(user_id, KIND_GATE, message, key=f"{severity}:{rule}",
|
| 233 |
-
tags=[rule.lower(), severity.lower()], weight=1.5)
|
| 234 |
-
|
| 235 |
-
def set_context(self, user_id: str, key: str, value: str) -> None:
|
| 236 |
-
"""Single-value session facts: current_branch, recent_pr, etc."""
|
| 237 |
-
self.remember(user_id, KIND_CONTEXT, str(value), key=key, tags=[key], weight=1.0)
|
| 238 |
-
|
| 239 |
-
def get_context(self, user_id: str, key: str, default=None):
|
| 240 |
-
table = self._table_for(user_id)
|
| 241 |
-
row = self._conn.execute(
|
| 242 |
-
f'SELECT content FROM "{table}" WHERE kind = ? AND key = ?', (KIND_CONTEXT, key)
|
| 243 |
-
).fetchone()
|
| 244 |
-
return row["content"] if row else default
|
| 245 |
-
|
| 246 |
-
# -- reading (memory decides what to surface) -----------------------
|
| 247 |
-
|
| 248 |
-
def recall(self, user_id: str, query: str, top_n: int = 8, kinds=None) -> list:
|
| 249 |
-
"""Rank stored memories against a free-text query.
|
| 250 |
-
|
| 251 |
-
Scoring is TF-IDF-style keyword overlap (computed on the fly over
|
| 252 |
-
this user's memories — no external embedding model), boosted by
|
| 253 |
-
recency and by how often the memory has recurred.
|
| 254 |
-
"""
|
| 255 |
-
table = self._table_for(user_id)
|
| 256 |
-
sql = f'SELECT * FROM "{table}"'
|
| 257 |
-
params = []
|
| 258 |
-
if kinds:
|
| 259 |
-
sql += f" WHERE kind IN ({','.join('?' for _ in kinds)})"
|
| 260 |
-
params.extend(kinds)
|
| 261 |
-
rows = self._conn.execute(sql, params).fetchall()
|
| 262 |
-
if not rows:
|
| 263 |
-
return []
|
| 264 |
-
|
| 265 |
-
query_terms = _tokenize(query)
|
| 266 |
-
if not query_terms:
|
| 267 |
-
return self._rank_by_importance(rows, top_n)
|
| 268 |
-
|
| 269 |
-
doc_freq = Counter()
|
| 270 |
-
doc_terms = []
|
| 271 |
-
for row in rows:
|
| 272 |
-
terms = Counter(_tokenize(f"{row['key']} {row['content']} {row['tags']}"))
|
| 273 |
-
doc_terms.append(terms)
|
| 274 |
-
doc_freq.update(terms.keys())
|
| 275 |
-
|
| 276 |
-
n_docs = len(rows)
|
| 277 |
-
now = datetime.now(timezone.utc)
|
| 278 |
-
scored = []
|
| 279 |
-
for row, terms in zip(rows, doc_terms):
|
| 280 |
-
overlap = sum(
|
| 281 |
-
terms[qt] * math.log(1 + n_docs / doc_freq[qt])
|
| 282 |
-
for qt in query_terms if terms.get(qt)
|
| 283 |
-
)
|
| 284 |
-
if overlap <= 0:
|
| 285 |
-
continue
|
| 286 |
-
recency = 1.0 / (1.0 + _age_days(row["updated_at"], now) / 14.0)
|
| 287 |
-
importance = math.log(1 + row["hit_count"]) * row["weight"]
|
| 288 |
-
score = overlap * (0.6 + 0.4 * recency) * (1.0 + 0.3 * importance)
|
| 289 |
-
scored.append(_to_entry(row, score))
|
| 290 |
-
|
| 291 |
-
scored.sort(key=lambda e: e.score, reverse=True)
|
| 292 |
-
return scored[:top_n]
|
| 293 |
-
|
| 294 |
-
def _rank_by_importance(self, rows, top_n: int) -> list:
|
| 295 |
-
now = datetime.now(timezone.utc)
|
| 296 |
-
scored = []
|
| 297 |
-
for row in rows:
|
| 298 |
-
recency = 1.0 / (1.0 + _age_days(row["updated_at"], now) / 14.0)
|
| 299 |
-
importance = math.log(1 + row["hit_count"]) * row["weight"]
|
| 300 |
-
scored.append(_to_entry(row, importance * recency))
|
| 301 |
-
scored.sort(key=lambda e: e.score, reverse=True)
|
| 302 |
-
return scored[:top_n]
|
| 303 |
-
|
| 304 |
-
def top_files(self, user_id: str, n: int = 5) -> list:
|
| 305 |
-
table = self._table_for(user_id)
|
| 306 |
-
rows = self._conn.execute(
|
| 307 |
-
f'SELECT * FROM "{table}" WHERE kind = ? ORDER BY hit_count DESC, updated_at DESC LIMIT ?',
|
| 308 |
-
(KIND_FILE, n),
|
| 309 |
-
).fetchall()
|
| 310 |
-
return [_to_entry(r) for r in rows]
|
| 311 |
-
|
| 312 |
-
def recent_patterns(self, user_id: str, n: int = 5) -> list:
|
| 313 |
-
table = self._table_for(user_id)
|
| 314 |
-
rows = self._conn.execute(
|
| 315 |
-
f'SELECT * FROM "{table}" WHERE kind = ? ORDER BY weight DESC, updated_at DESC LIMIT ?',
|
| 316 |
-
(KIND_PATTERN, n),
|
| 317 |
-
).fetchall()
|
| 318 |
-
return [_to_entry(r) for r in rows]
|
| 319 |
-
|
| 320 |
-
def recent_gate_corrections(self, user_id: str, n: int = 5) -> list:
|
| 321 |
-
table = self._table_for(user_id)
|
| 322 |
-
rows = self._conn.execute(
|
| 323 |
-
f'SELECT * FROM "{table}" WHERE kind = ? ORDER BY updated_at DESC LIMIT ?',
|
| 324 |
-
(KIND_GATE, n),
|
| 325 |
-
).fetchall()
|
| 326 |
-
return [_to_entry(r) for r in rows]
|
| 327 |
-
|
| 328 |
-
def session_brief(self, user_id: str, first_message: str = "", top_n: int = 8) -> dict:
|
| 329 |
-
"""Everything Rivet should know when a session starts: memories
|
| 330 |
-
relevant to the first message, plus standing context that should
|
| 331 |
-
always be visible (frequent files, recurring patterns, gate history,
|
| 332 |
-
branch/PR state) regardless of what was asked."""
|
| 333 |
-
return {
|
| 334 |
-
"relevant": self.recall(user_id, first_message, top_n=top_n) if first_message else [],
|
| 335 |
-
"top_files": self.top_files(user_id),
|
| 336 |
-
"known_patterns": self.recent_patterns(user_id),
|
| 337 |
-
"gate_history": self.recent_gate_corrections(user_id),
|
| 338 |
-
"current_branch": self.get_context(user_id, "current_branch"),
|
| 339 |
-
"recent_pr": self.get_context(user_id, "recent_pr"),
|
| 340 |
-
}
|
| 341 |
-
|
| 342 |
-
def to_system_block(self, user_id: str, first_message: str = "") -> str:
|
| 343 |
-
"""Format session_brief as a text block ready to inject into the
|
| 344 |
-
model's system context, in the same spirit as RivetContext in
|
| 345 |
-
context_loader.py."""
|
| 346 |
-
brief = self.session_brief(user_id, first_message)
|
| 347 |
-
parts = ["# USER MEMORY\n"]
|
| 348 |
-
|
| 349 |
-
if brief["current_branch"] or brief["recent_pr"]:
|
| 350 |
-
parts.append("## Current Work")
|
| 351 |
-
if brief["current_branch"]:
|
| 352 |
-
parts.append(f"- Branch: {brief['current_branch']}")
|
| 353 |
-
if brief["recent_pr"]:
|
| 354 |
-
parts.append(f"- Recent PR: {brief['recent_pr']}")
|
| 355 |
-
parts.append("")
|
| 356 |
-
|
| 357 |
-
if brief["top_files"]:
|
| 358 |
-
parts.append("## Frequently Touched Files")
|
| 359 |
-
parts.extend(f"- {e.key}: {e.content}" for e in brief["top_files"])
|
| 360 |
-
parts.append("")
|
| 361 |
-
|
| 362 |
-
if brief["known_patterns"]:
|
| 363 |
-
parts.append("## Recurring Patterns (flag preemptively)")
|
| 364 |
-
parts.extend(f"- {e.key}: {e.content}" for e in brief["known_patterns"])
|
| 365 |
-
parts.append("")
|
| 366 |
-
|
| 367 |
-
if brief["gate_history"]:
|
| 368 |
-
parts.append("## Discipline Gate History")
|
| 369 |
-
parts.extend(f"- {e.content}" for e in brief["gate_history"])
|
| 370 |
-
parts.append("")
|
| 371 |
-
|
| 372 |
-
if brief["relevant"]:
|
| 373 |
-
parts.append("## Relevant To This Question")
|
| 374 |
-
parts.extend(f"- {e.content}" for e in brief["relevant"])
|
| 375 |
-
parts.append("")
|
| 376 |
-
|
| 377 |
-
return "\n".join(parts).strip()
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
def _cli():
|
| 381 |
-
parser = argparse.ArgumentParser(description="Rivet memory database utility")
|
| 382 |
-
parser.add_argument("command", choices=["init", "demo"])
|
| 383 |
-
parser.add_argument("--db", default=str(DEFAULT_DB_PATH))
|
| 384 |
-
args = parser.parse_args()
|
| 385 |
-
|
| 386 |
-
if args.command == "init":
|
| 387 |
-
store = MemoryStore(args.db)
|
| 388 |
-
print(f"Memory database ready at {store.db_path}")
|
| 389 |
-
store.close()
|
| 390 |
-
return
|
| 391 |
-
|
| 392 |
-
# demo: quick smoke test exercising the write/recall paths
|
| 393 |
-
store = MemoryStore(args.db)
|
| 394 |
-
user = "demo_user"
|
| 395 |
-
store.remember_file(user, "src/routes/auth.ts", "adding refresh-token rotation")
|
| 396 |
-
store.remember_qa(user, "why does the webhook route 500 on missing signature",
|
| 397 |
-
"STRIPE_SECRET falls back to '' — Audit C2, reject instead of skip")
|
| 398 |
-
store.remember_pattern(user, "proposes_destructive_migration",
|
| 399 |
-
"suggested DROP COLUMN twice — always steer to additive migrations")
|
| 400 |
-
store.remember_gate_correction(user, "webhook_bypass",
|
| 401 |
-
"Webhook signature bypass pattern (Audit C2) flagged and blocked",
|
| 402 |
-
severity="BLOCK")
|
| 403 |
-
store.set_context(user, "current_branch", "feat/refresh-token-rotation")
|
| 404 |
-
|
| 405 |
-
print(store.to_system_block(user, first_message="can I touch the webhook signature check?"))
|
| 406 |
-
store.close()
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
if __name__ == "__main__":
|
| 410 |
-
_cli()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|