Files
shared/scripts/role/memory.py
T

734 lines
27 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# ==============================================================================
# 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
# inbox(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理
# 素材並套用整理結果(分類/去重/標籤/總結/壓縮歸檔),(4) 依使用頻率
# 遺忘日常與其他類記憶。
# 更新時間:2026/07/28 11:50:00
# 相依:Python 3 標準庫。
# 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
# ==============================================================================
import argparse
import gzip
import hashlib
import json
import os
import re
import shutil
import sys
from datetime import datetime, timedelta, timezone
# ------------------------------------------------------------------------------
# 常數:分類、載入策略、遺忘規則
# ------------------------------------------------------------------------------
# 六個分類的目錄名(英文,跨平台安全)與中文標籤
CATEGORIES = ["important", "interest", "news", "skill", "daily", "other"]
CATEGORY_LABELS = {
"important": "重要",
"interest": "興趣",
"news": "新知",
"skill": "技能",
"daily": "日常",
"other": "其他",
}
# 中文分類名反查(模型可能直接輸出中文)
LABEL_TO_CATEGORY = {label: key for key, label in CATEGORY_LABELS.items()}
# 載入策略:重要與興趣載入全文,其餘僅載入總結與標籤
FULL_CATEGORIES = ["important", "interest"]
# 摘要載入順序:技能 → 新知 → 日常 → 其他
DIGEST_CATEGORIES = ["skill", "news", "daily", "other"]
# 遺忘規則:(未更新天數門檻, 命中次數上限);只套用於日常與其他
FORGET_RULES = {"daily": (14, 1), "other": (7, 1)}
# 單次睡眠整理最多處理的 inbox 筆數,其餘留待下個睡眠週期
SLEEP_BATCH = 60
# 送進模型的素材字元上限
COLLECT_LIMIT = 40000
# 單則記憶壓縮後的內容字元上限
CONTENT_LIMIT = 1200
TAIPEI = timezone(timedelta(hours=8))
STAMP_FORMAT = "%Y/%m/%d %H:%M:%S"
# ------------------------------------------------------------------------------
# 路徑與時間
# ------------------------------------------------------------------------------
def now_stamp():
"""回傳台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串。"""
return datetime.now(TAIPEI).strftime(STAMP_FORMAT)
def parse_stamp(value):
"""把 yyyy/MM/dd HH:mm:ss 字串解析成帶時區的 datetime,失敗回 None。"""
if not isinstance(value, str) or not value.strip():
return None
try:
return datetime.strptime(value.strip(), STAMP_FORMAT).replace(tzinfo=TAIPEI)
except ValueError:
return None
def memory_root(role):
"""回傳指定角色的記憶根目錄(可用 ROLE_MEMORY_HOME 覆寫預設 ~/.memory)。"""
base = os.environ.get("ROLE_MEMORY_HOME") or os.path.join(os.path.expanduser("~"), ".memory")
return os.path.join(base, role)
def ensure_layout(role):
"""建立角色記憶目錄結構(inbox、六個分類、archive),回傳根目錄。"""
root = memory_root(role)
for sub in ["inbox", "archive/raw", "archive/forgotten"] + CATEGORIES:
os.makedirs(os.path.join(root, sub), exist_ok=True)
return root
def state_path(role):
"""回傳角色記憶狀態檔(state.json)的路徑。"""
return os.path.join(memory_root(role), "state.json")
def read_state(role):
"""讀取狀態檔;不存在或損壞時回空 dict。"""
try:
with open(state_path(role), encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def write_state(role, patch):
"""把 patch 併入狀態檔後寫回(整份覆寫,內容極小)。"""
state = read_state(role)
state.update(patch)
ensure_layout(role)
with open(state_path(role), "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
return state
# ------------------------------------------------------------------------------
# 記憶檔格式:YAML 風格 frontmatter 內文
# ------------------------------------------------------------------------------
def normalize_category(value):
"""把模型輸出的分類(英文或中文)正規化為分類鍵;無法判定時回 other。"""
raw = (value or "").strip().lower()
if raw in CATEGORIES:
return raw
return LABEL_TO_CATEGORY.get((value or "").strip(), "other")
def normalize_tags(value):
"""把標籤(list 或逗號分隔字串)正規化為去重後的小寫標籤 list,最多 6 個。"""
if isinstance(value, str):
parts = re.split(r"[,、|]", value)
elif isinstance(value, list):
parts = [str(item) for item in value]
else:
parts = []
tags = []
for part in parts:
tag = part.strip().strip("[]#").strip()
if tag and tag.lower() not in [t.lower() for t in tags]:
tags.append(tag)
return tags[:6]
def one_line(value, limit=120):
"""把文字壓成單行並截斷,用於 summary 欄位。"""
text = re.sub(r"\s+", " ", str(value or "")).strip()
return text[:limit]
def dump_memory(meta, content):
"""把 meta 與內文組成記憶檔全文(frontmatter 內文)。"""
lines = ["---"]
for key in ["id", "category", "summary", "tags", "created", "updated", "hits", "sources"]:
if key not in meta:
continue
value = meta[key]
if isinstance(value, list):
value = "[" + ", ".join(str(item) for item in value) + "]"
lines.append(f"{key}: {value}")
lines.append("---")
lines.append("")
lines.append(content.strip())
lines.append("")
return "\n".join(lines)
def load_memory(path):
"""讀取單一記憶檔,回傳 (meta dict, 內文);讀取失敗回 (None, "")。"""
try:
with open(path, encoding="utf-8") as fh:
raw = fh.read()
except OSError:
return None, ""
meta = {"path": path, "hits": 0, "tags": []}
body = raw
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
body = parts[2]
for line in parts[1].splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if key in ("tags", "sources"):
meta[key] = normalize_tags(value.strip("[]"))
elif key == "hits":
meta[key] = int(value) if value.isdigit() else 0
else:
meta[key] = value
meta.setdefault("id", os.path.splitext(os.path.basename(path))[0])
meta.setdefault("summary", "")
meta.setdefault("created", "")
meta.setdefault("updated", meta.get("created", ""))
return meta, body.strip()
def new_id(seed):
"""以時間與內容雜湊產生記憶 id,確保同一秒多筆也不碰撞。"""
digest = hashlib.sha1(seed.encode("utf-8", "replace")).hexdigest()[:6]
return f"{datetime.now(TAIPEI).strftime('%Y%m%d-%H%M%S')}-{digest}"
def list_memories(role, category):
"""列出某分類下的所有記憶(依 updated 新到舊排序)。"""
directory = os.path.join(memory_root(role), category)
items = []
if not os.path.isdir(directory):
return items
for name in sorted(os.listdir(directory)):
if not name.endswith(".md"):
continue
meta, content = load_memory(os.path.join(directory, name))
if meta is None:
continue
meta["category"] = category
items.append((meta, content))
items.sort(key=lambda item: item[0].get("updated") or "", reverse=True)
return items
def list_inbox(role):
"""列出 inbox 內尚未整理的記憶(依檔名,即時間先後排序)。"""
directory = os.path.join(memory_root(role), "inbox")
items = []
if not os.path.isdir(directory):
return items
for name in sorted(os.listdir(directory)):
if not name.endswith(".md"):
continue
meta, content = load_memory(os.path.join(directory, name))
if meta is not None:
items.append((meta, content))
return items
def find_memory(role, memory_id):
"""依 id 在六個分類中尋找記憶檔,回傳 (meta, 內文);找不到回 (None, "")。"""
for category in CATEGORIES:
path = os.path.join(memory_root(role), category, f"{memory_id}.md")
if os.path.isfile(path):
meta, content = load_memory(path)
if meta is not None:
meta["category"] = category
return meta, content
return None, ""
def archive_file(path, destination_dir):
"""把檔案 gzip 後搬到歸檔目錄,原檔刪除;失敗時保留原檔。"""
os.makedirs(destination_dir, exist_ok=True)
target = os.path.join(destination_dir, os.path.basename(path) + ".gz")
try:
with open(path, "rb") as src, gzip.open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
os.remove(path)
return True
except OSError:
return False
# ------------------------------------------------------------------------------
# 子命令:write —— 由 Stop hook 寫入一則未整理記憶
# ------------------------------------------------------------------------------
FIELD_PATTERN = re.compile(r"^\s*(CATEGORY|SUMMARY|TAGS|CONTENT)\s*[:]\s*(.*)$", re.IGNORECASE)
def parse_capture(text):
"""
解析 Stop hook 濃縮器輸出的四欄格式(CATEGORYSUMMARYTAGSCONTENT)。
模型可能夾帶前後贅字,故逐行掃描欄位標記,CONTENT 之後的所有內容視為內文。
"""
category = summary = ""
tags = []
content_lines = []
in_content = False
for line in text.splitlines():
match = FIELD_PATTERN.match(line)
if match and not (in_content and match.group(1).upper() != "CONTENT"):
field = match.group(1).upper()
value = match.group(2)
if field == "CATEGORY":
category = value
elif field == "SUMMARY":
summary = value
elif field == "TAGS":
tags = normalize_tags(value)
elif field == "CONTENT":
in_content = True
if value.strip():
content_lines.append(value)
continue
if in_content:
content_lines.append(line)
return category, summary, tags, "\n".join(content_lines).strip()
def cmd_write(args):
"""把 stdin 的濃縮結果寫成一則 inbox 記憶。"""
raw = sys.stdin.read()
category, summary, tags, content = parse_capture(raw)
if not content and not summary:
return 1
if not content:
content = summary
content = content[:CONTENT_LIMIT]
stamp = now_stamp()
meta = {
"id": new_id(content + stamp),
"category": normalize_category(category),
"summary": one_line(summary) or one_line(content),
"tags": tags,
"created": stamp,
"updated": stamp,
"hits": 1,
}
if args.project:
meta["sources"] = [args.project]
ensure_layout(args.role)
path = os.path.join(memory_root(args.role), "inbox", f"{meta['id']}.md")
with open(path, "w", encoding="utf-8") as fh:
fh.write(dump_memory(meta, content))
sys.stdout.write(meta["id"])
return 0
# ------------------------------------------------------------------------------
# 子命令:seed —— 新建角色時寫入已整理的初始記憶
# ------------------------------------------------------------------------------
def cmd_seed(args):
"""把 stdin 寫成一則已整理記憶,用於新建角色時灌入背景資料。"""
content = sys.stdin.read().strip()
if not content and not args.summary:
return 1
if not content:
content = args.summary
content = content[:CONTENT_LIMIT]
stamp = now_stamp()
category = normalize_category(args.category)
meta = {
"id": new_id(content + args.summary + stamp),
"category": category,
"summary": one_line(args.summary) or one_line(content),
"tags": normalize_tags(args.tags),
"created": stamp,
"updated": stamp,
"hits": 1,
}
if args.source:
meta["sources"] = [args.source]
ensure_layout(args.role)
path = os.path.join(memory_root(args.role), category, f"{meta['id']}.md")
with open(path, "w", encoding="utf-8") as fh:
fh.write(dump_memory(meta, content))
sys.stdout.write(meta["id"])
return 0
# ------------------------------------------------------------------------------
# 子命令:load —— 產生 SessionStart 要注入的記憶區塊
# ------------------------------------------------------------------------------
def cmd_load(args):
"""
組出載入用記憶區塊:重要與興趣載入全文,其餘依技能→新知→日常→其他只載總結與標籤。
超過字元上限時截斷並標明,避免佔滿 context。
"""
limit = args.limit
blocks = []
total_full = 0
for category in FULL_CATEGORIES:
items = list_memories(args.role, category)
if not items:
continue
lines = [f"### {CATEGORY_LABELS[category]}記憶(全文)"]
for meta, content in items:
tags = "、".join(meta.get("tags") or []) or "無標籤"
lines.append(f"- **{meta.get('summary') or '(無總結)'}**(標籤:{tags}")
for line in content.splitlines():
if line.strip():
lines.append(f" {line.strip()}")
total_full += 1
blocks.append("\n".join(lines))
digest_lines = []
digest_count = 0
for category in DIGEST_CATEGORIES:
items = list_memories(args.role, category)
if not items:
continue
digest_lines.append(f"### {CATEGORY_LABELS[category]}記憶(總結)")
for meta, _ in items:
tags = "、".join(meta.get("tags") or []) or "無標籤"
digest_lines.append(f"- {meta.get('summary') or '(無總結)'}(標籤:{tags}")
digest_count += 1
if digest_lines:
blocks.append("\n".join(digest_lines))
pending = len(list_inbox(args.role))
if pending:
blocks.append(f"> 尚有 {pending} 則未整理記憶,將於下次睡眠時段歸檔。")
if not blocks:
return 1
text = "\n\n".join(blocks)
if len(text) > limit:
text = text[:limit] + f"\n\n> (記憶內容超過 {limit} 字元已截斷,完整記憶仍保存在磁碟)"
sys.stdout.write(text)
return 0
# ------------------------------------------------------------------------------
# 子命令:collect —— 睡眠整理前輸出待整理素材
# ------------------------------------------------------------------------------
def cmd_collect(args):
"""輸出送進模型的整理素材:inbox 待整理項目 + 既有記憶索引(供去重比對)。"""
inbox = list_inbox(args.role)[:SLEEP_BATCH]
if not inbox:
return 1
lines = ["=== INBOX(待整理,每則以 id 標識)==="]
for meta, content in inbox:
lines.append(f"--- id: {meta['id']} | 時間: {meta.get('created', '-')} ---")
lines.append(f"初判分類: {CATEGORY_LABELS.get(meta.get('category', 'other'), '其他')}")
lines.append(f"初判總結: {meta.get('summary', '')}")
lines.append(f"初判標籤: {'、'.join(meta.get('tags') or []) or '無'}")
lines.append("內容:")
lines.append(content)
lines.append("")
lines.append("=== EXISTING(既有記憶索引,供去重與合併判斷)===")
existing = 0
for category in CATEGORIES:
for meta, _ in list_memories(args.role, category):
tags = "、".join(meta.get("tags") or []) or "無"
lines.append(
f"- id: {meta['id']} | 分類: {CATEGORY_LABELS[category]} | 標籤: {tags} | 總結: {meta.get('summary', '')}"
)
existing += 1
if not existing:
lines.append("(尚無既有記憶)")
text = "\n".join(lines)
if len(text) > COLLECT_LIMIT:
text = text[:COLLECT_LIMIT] + "\n…(素材過長已截斷,其餘留待下個睡眠週期)…"
sys.stdout.write(text)
return 0
# ------------------------------------------------------------------------------
# 子命令:apply —— 套用睡眠整理結果
# ------------------------------------------------------------------------------
def extract_json(text):
"""從模型輸出中取出第一個 JSON 物件(容忍 code fence 與前後贅字)。"""
stripped = text.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", stripped, re.DOTALL)
if fence:
stripped = fence.group(1).strip()
start = stripped.find("{")
end = stripped.rfind("}")
if start < 0 or end <= start:
return None
try:
return json.loads(stripped[start : end + 1])
except ValueError:
return None
def cmd_apply(args):
"""
讀取 stdin 的整理結果 JSON,寫入分類記憶並歸檔對應的 inbox 原始檔。
action 支援 new(新建)/merge(併入既有記憶)/drop(判定無保存價值)。
未被提及的 inbox 檔一律保留,留待下個睡眠週期,避免整理失敗造成記憶遺失。
"""
data = extract_json(sys.stdin.read())
if not isinstance(data, dict):
sys.stderr.write("整理結果非合法 JSON\n")
return 1
entries = data.get("memories")
if not isinstance(entries, list) or not entries:
sys.stderr.write("整理結果不含 memories\n")
return 1
ensure_layout(args.role)
root = memory_root(args.role)
stamp = now_stamp()
counts = {"new": 0, "merge": 0, "drop": 0}
consumed = []
for entry in entries:
if not isinstance(entry, dict):
continue
action = str(entry.get("action") or "new").strip().lower()
sources = [str(item).strip() for item in (entry.get("from") or []) if str(item).strip()]
if action == "drop":
consumed.extend(sources)
counts["drop"] += 1
continue
content = str(entry.get("content") or "").strip()[:CONTENT_LIMIT]
summary = one_line(entry.get("summary"))
tags = normalize_tags(entry.get("tags"))
if not content and not summary:
continue
if action == "merge":
target_id = str(entry.get("target") or "").strip()
meta, old_content = find_memory(args.role, target_id)
if meta is None:
action = "new"
else:
category = normalize_category(entry.get("category") or meta.get("category"))
merged_tags = normalize_tags((meta.get("tags") or []) + tags)
new_meta = {
"id": meta["id"],
"category": category,
"summary": summary or meta.get("summary", ""),
"tags": merged_tags,
"created": meta.get("created") or stamp,
"updated": stamp,
"hits": int(meta.get("hits") or 0) + 1,
}
old_path = meta["path"]
new_path = os.path.join(root, category, f"{meta['id']}.md")
with open(new_path, "w", encoding="utf-8") as fh:
fh.write(dump_memory(new_meta, content or old_content))
if os.path.abspath(old_path) != os.path.abspath(new_path):
try:
os.remove(old_path)
except OSError:
pass
consumed.extend(sources)
counts["merge"] += 1
continue
category = normalize_category(entry.get("category"))
meta = {
"id": new_id(content + summary + stamp),
"category": category,
"summary": summary or one_line(content),
"tags": tags,
"created": stamp,
"updated": stamp,
"hits": 1,
}
with open(os.path.join(root, category, f"{meta['id']}.md"), "w", encoding="utf-8") as fh:
fh.write(dump_memory(meta, content or summary))
consumed.extend(sources)
counts["new"] += 1
archived = 0
month_dir = os.path.join(root, "archive", "raw", datetime.now(TAIPEI).strftime("%Y-%m"))
for source_id in set(consumed):
path = os.path.join(root, "inbox", f"{source_id}.md")
if os.path.isfile(path) and archive_file(path, month_dir):
archived += 1
write_state(args.role, {"last_sleep": stamp, "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())})
sys.stdout.write(
f"新增 {counts['new']} 則、合併 {counts['merge']} 則、捨棄 {counts['drop']} 則、歸檔原始記憶 {archived} 則"
)
return 0
# ------------------------------------------------------------------------------
# 子命令:forget —— 依使用頻率遺忘日常與其他
# ------------------------------------------------------------------------------
def cmd_forget(args):
"""把日常/其他分類中久未更新且命中次數低的記憶壓縮到 archive/forgotten 後移除。"""
root = ensure_layout(args.role)
now = datetime.now(TAIPEI)
forgotten = []
for category, (days, max_hits) in FORGET_RULES.items():
for meta, _ in list_memories(args.role, category):
updated = parse_stamp(meta.get("updated")) or parse_stamp(meta.get("created"))
if updated is None:
continue
if (now - updated).days < days:
continue
if int(meta.get("hits") or 0) > max_hits:
continue
if args.dry_run:
forgotten.append(f"{CATEGORY_LABELS[category]}{meta.get('summary', '')}")
continue
if archive_file(meta["path"], os.path.join(root, "archive", "forgotten")):
forgotten.append(f"{CATEGORY_LABELS[category]}{meta.get('summary', '')}")
if not forgotten:
sys.stdout.write("沒有符合遺忘條件的記憶")
return 0
prefix = "(預覽)" if args.dry_run else ""
sys.stdout.write(f"{prefix}遺忘 {len(forgotten)} 則:\n" + "\n".join(f"- {item}" for item in forgotten))
if not args.dry_run:
write_state(args.role, {"last_forget": now_stamp()})
return 0
# ------------------------------------------------------------------------------
# 子命令:stats —— 供 skill 與診斷顯示記憶概況
# ------------------------------------------------------------------------------
def cmd_stats(args):
"""輸出記憶統計(各分類筆數、待整理筆數、上次整理時間)。"""
ensure_layout(args.role)
state = read_state(args.role)
rows = [f"| 分類 | 筆數 |", "| --- | --- |"]
for category in CATEGORIES:
rows.append(f"| {CATEGORY_LABELS[category]} | {len(list_memories(args.role, category))} |")
rows.append(f"| 待整理(inbox | {len(list_inbox(args.role))} |")
rows.append("")
rows.append(f"- 記憶目錄:{memory_root(args.role)}")
rows.append(f"- 上次睡眠整理:{state.get('last_sleep', '尚未整理')}")
rows.append(f"- 上次遺忘:{state.get('last_forget', '尚未執行')}")
sys.stdout.write("\n".join(rows))
return 0
# ------------------------------------------------------------------------------
# 子命令:need-sleep —— 判斷是否需要補跑睡眠整理
# ------------------------------------------------------------------------------
def cmd_mark_sleep(args):
"""把本次睡眠週期標記為已整理(inbox 為空、無素材可整理時使用)。"""
write_state(args.role, {"last_sleep": now_stamp(), "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())})
sys.stdout.write("已更新上次整理時間")
return 0
def cmd_need_sleep(args):
"""
判斷是否需要補跑整理:距上次整理超過門檻小時數且 inbox 有內容。
輸出 yes/no,供 shell 直接判斷(不用解析 JSON)。
"""
if not list_inbox(args.role):
sys.stdout.write("no")
return 0
state = read_state(args.role)
last = parse_stamp(state.get("last_sleep"))
if last is None:
sys.stdout.write("yes")
return 0
hours = (datetime.now(TAIPEI) - last).total_seconds() / 3600
sys.stdout.write("yes" if hours >= args.hours else "no")
return 0
# ------------------------------------------------------------------------------
# CLI
# ------------------------------------------------------------------------------
def build_parser():
"""建立子命令解析器。"""
parser = argparse.ArgumentParser(description="角色記憶儲存引擎")
sub = parser.add_subparsers(dest="command", required=True)
write = sub.add_parser("write", help="自 stdin 讀濃縮結果寫入 inbox")
write.add_argument("--role", required=True)
write.add_argument("--project", default="")
write.set_defaults(func=cmd_write)
seed = sub.add_parser("seed", help="自 stdin 寫入已整理的初始記憶")
seed.add_argument("--role", required=True)
seed.add_argument("--category", default="important")
seed.add_argument("--summary", required=True)
seed.add_argument("--tags", default="")
seed.add_argument("--source", default="")
seed.set_defaults(func=cmd_seed)
load = sub.add_parser("load", help="輸出 SessionStart 要注入的記憶區塊")
load.add_argument("--role", required=True)
load.add_argument("--limit", type=int, default=int(os.environ.get("ROLE_LOAD_LIMIT", "8000")))
load.set_defaults(func=cmd_load)
collect = sub.add_parser("collect", help="輸出睡眠整理素材")
collect.add_argument("--role", required=True)
collect.set_defaults(func=cmd_collect)
apply_cmd = sub.add_parser("apply", help="自 stdin 讀整理結果 JSON 並套用")
apply_cmd.add_argument("--role", required=True)
apply_cmd.set_defaults(func=cmd_apply)
forget = sub.add_parser("forget", help="依使用頻率遺忘日常與其他記憶")
forget.add_argument("--role", required=True)
forget.add_argument("--dry-run", action="store_true")
forget.set_defaults(func=cmd_forget)
stats = sub.add_parser("stats", help="輸出記憶統計")
stats.add_argument("--role", required=True)
stats.set_defaults(func=cmd_stats)
mark = sub.add_parser("mark-sleep", help="標記本次睡眠週期已整理")
mark.add_argument("--role", required=True)
mark.set_defaults(func=cmd_mark_sleep)
need = sub.add_parser("need-sleep", help="判斷是否需要補跑睡眠整理")
need.add_argument("--role", required=True)
need.add_argument("--hours", type=float, default=20.0)
need.set_defaults(func=cmd_need_sleep)
return parser
def main(argv):
"""CLI 進入點。"""
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))