claude-nb 0.3.0

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.
Files changed (65) hide show
  1. package/LICENSE +38 -0
  2. package/Makefile +60 -0
  3. package/README.md +63 -0
  4. package/VERSION +1 -0
  5. package/bin/_pip_entry.py +25 -0
  6. package/bin/board +287 -0
  7. package/bin/cnb +150 -0
  8. package/bin/cnb.js +33 -0
  9. package/bin/dispatcher +151 -0
  10. package/bin/dispatcher-watchdog +57 -0
  11. package/bin/doctor +328 -0
  12. package/bin/init +316 -0
  13. package/bin/registry +347 -0
  14. package/bin/swarm +896 -0
  15. package/lib/__init__.py +1 -0
  16. package/lib/board_admin.py +128 -0
  17. package/lib/board_bbs.py +99 -0
  18. package/lib/board_bug.py +161 -0
  19. package/lib/board_db.py +262 -0
  20. package/lib/board_lock.py +113 -0
  21. package/lib/board_mailbox.py +145 -0
  22. package/lib/board_maintenance.py +237 -0
  23. package/lib/board_msg.py +230 -0
  24. package/lib/board_task.py +200 -0
  25. package/lib/board_view.py +366 -0
  26. package/lib/board_vote.py +164 -0
  27. package/lib/build_lock.py +221 -0
  28. package/lib/cli.py +34 -0
  29. package/lib/common.py +285 -0
  30. package/lib/concerns/__init__.py +42 -0
  31. package/lib/concerns/adaptive_throttle.py +26 -0
  32. package/lib/concerns/base.py +25 -0
  33. package/lib/concerns/bug_sla_checker.py +32 -0
  34. package/lib/concerns/config.py +22 -0
  35. package/lib/concerns/coral_manager.py +61 -0
  36. package/lib/concerns/coral_poker.py +57 -0
  37. package/lib/concerns/file_watcher.py +127 -0
  38. package/lib/concerns/health_checker.py +72 -0
  39. package/lib/concerns/helpers.py +152 -0
  40. package/lib/concerns/idle_detector.py +56 -0
  41. package/lib/concerns/idle_killer.py +41 -0
  42. package/lib/concerns/idle_nudger.py +38 -0
  43. package/lib/concerns/inbox_nudger.py +34 -0
  44. package/lib/concerns/resource_monitor.py +47 -0
  45. package/lib/concerns/session_keepalive.py +23 -0
  46. package/lib/concerns/time_announcer.py +34 -0
  47. package/lib/crypto.py +92 -0
  48. package/lib/health.py +187 -0
  49. package/lib/inject.py +164 -0
  50. package/lib/migrate.py +109 -0
  51. package/lib/monitor.py +373 -0
  52. package/lib/panel.py +137 -0
  53. package/lib/resources.py +341 -0
  54. package/migrations/001_foreign_keys.sql +77 -0
  55. package/migrations/002_session_persona.sql +1 -0
  56. package/migrations/003_mailbox.sql +9 -0
  57. package/package.json +28 -0
  58. package/pyproject.toml +71 -0
  59. package/registry/0001-meridian.json +12 -0
  60. package/registry/0002-forge.json +12 -0
  61. package/registry/0003-lead.json +12 -0
  62. package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
  63. package/registry/GENESIS.json +9 -0
  64. package/registry/pubkeys.json +5 -0
  65. package/schema.sql +138 -0
@@ -0,0 +1 @@
1
+ # lib/ package — Python modules for cnb
@@ -0,0 +1,128 @@
1
+ """board_admin — suspend / resume / kudos / kudos-list."""
2
+
3
+ import subprocess
4
+ import time
5
+
6
+ from lib.board_db import BoardDB, ts
7
+
8
+
9
+ def cmd_suspend(db: BoardDB, identity: str, args: list[str]) -> None:
10
+ name = identity.lower()
11
+ if not args:
12
+ print("Usage: ./board --as <name> suspend <session>")
13
+ raise SystemExit(1)
14
+ target = args[0].lower()
15
+
16
+ session_exists = db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (target,))
17
+ if not session_exists:
18
+ print(f"ERROR: 会话 '{target}' 不存在")
19
+ raise SystemExit(1)
20
+
21
+ already = db.scalar("SELECT COUNT(*) FROM suspended WHERE name=?", (target,))
22
+ if already:
23
+ print(f"{target} 已在停工名单中")
24
+ return
25
+
26
+ db.execute("INSERT INTO suspended(name, suspended_by) VALUES (?, ?)", (target, name))
27
+
28
+ sf = db.env.suspended_file
29
+ lines = sf.read_text().splitlines() if sf.exists() else []
30
+ if target not in lines:
31
+ lines.append(target)
32
+ sf.write_text("\n".join(lines) + "\n")
33
+
34
+ print(f"{target}: 已停工")
35
+
36
+ prefix = db.env.prefix
37
+ sess = f"{prefix}-{target}"
38
+ r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
39
+ if r.returncode == 0:
40
+ subprocess.run(
41
+ ["tmux", "send-keys", "-t", sess, "/exit", "Enter"],
42
+ capture_output=True,
43
+ )
44
+ time.sleep(2)
45
+ subprocess.run(["tmux", "kill-session", "-t", sess], capture_output=True)
46
+ print(f"{target}: tmux session 已关闭")
47
+
48
+ now = ts()
49
+ db.execute(
50
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, 'SYSTEM', 'all', ?)",
51
+ (now, f"SUSPEND {target} by {name}"),
52
+ )
53
+
54
+
55
+ def cmd_resume(db: BoardDB, identity: str, args: list[str]) -> None:
56
+ name = identity.lower()
57
+ if not args:
58
+ print("Usage: ./board --as <name> resume <session>")
59
+ raise SystemExit(1)
60
+ target = args[0].lower()
61
+
62
+ exists = db.scalar("SELECT COUNT(*) FROM suspended WHERE name=?", (target,))
63
+ if not exists:
64
+ print(f"ERROR: {target} 不在停工名单中")
65
+ raise SystemExit(1)
66
+
67
+ db.execute("DELETE FROM suspended WHERE name=?", (target,))
68
+
69
+ sf = db.env.suspended_file
70
+ if sf.exists():
71
+ lines = [l for l in sf.read_text().splitlines() if l != target]
72
+ sf.write_text("\n".join(lines) + "\n" if lines else "")
73
+
74
+ print(f"{target}: 已恢复")
75
+ now = ts()
76
+ db.execute(
77
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, 'SYSTEM', 'all', ?)",
78
+ (now, f"RESUME {target} by {name}"),
79
+ )
80
+
81
+
82
+ def cmd_kudos(db: BoardDB, identity: str, args: list[str]) -> None:
83
+ name = identity.lower()
84
+ if len(args) < 2:
85
+ print("Usage: ./board --as <name> kudos <target> <reason> [--evidence <commit/link>]")
86
+ raise SystemExit(1)
87
+
88
+ evidence = ""
89
+ clean_args = []
90
+ i = 0
91
+ while i < len(args):
92
+ if args[i] in ("--evidence", "-e") and i + 1 < len(args):
93
+ evidence = args[i + 1]
94
+ i += 2
95
+ continue
96
+ clean_args.append(args[i])
97
+ i += 1
98
+
99
+ target = clean_args[0].lower()
100
+ reason = " ".join(clean_args[1:])
101
+
102
+ if name == target:
103
+ print("ERROR: cannot kudos yourself")
104
+ raise SystemExit(1)
105
+
106
+ db.execute(
107
+ "INSERT INTO kudos(sender, target, reason, evidence) VALUES (?, ?, ?, ?)",
108
+ (name, target, reason, evidence or None),
109
+ )
110
+ now = ts()
111
+ db.execute(
112
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
113
+ (now, name, f"[KUDOS] → {target}: {reason}"),
114
+ )
115
+ print(f"OK kudos sent to {target} (visible to all)")
116
+
117
+
118
+ def cmd_kudos_list(db: BoardDB) -> None:
119
+ print("=== Kudos Board ===\n")
120
+ print("Leaderboard:")
121
+ for who, count in db.query("SELECT target, COUNT(*) as c FROM kudos GROUP BY target ORDER BY c DESC"):
122
+ print(f" {who}: {count} kudos")
123
+ print("\nRecent:")
124
+ rows = db.query(
125
+ "SELECT '[' || ts || '] ' || sender || ' → ' || target || ': ' || reason FROM kudos ORDER BY id DESC LIMIT 10"
126
+ )
127
+ for (line,) in reversed(rows):
128
+ print(f" {line}")
@@ -0,0 +1,99 @@
1
+ """board_bbs — forum commands: post / reply / thread / threads."""
2
+
3
+ import hashlib
4
+
5
+ from lib.board_db import BoardDB, ts
6
+
7
+
8
+ def cmd_post(db: BoardDB, identity: str, args: list[str]) -> None:
9
+ name = identity.lower()
10
+ if len(args) < 2:
11
+ print("Usage: ./board --as <name> post <标题> <内容>")
12
+ raise SystemExit(1)
13
+ title = args[0]
14
+ now = ts()
15
+ tid = hashlib.sha256(f"{title}{now}{identity}".encode()).hexdigest()[:6]
16
+
17
+ db.execute(
18
+ "INSERT INTO threads(id, title, author) VALUES (?, ?, ?)",
19
+ (tid, title, name),
20
+ )
21
+ db.execute(
22
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
23
+ (now, name, f"[BBS] 新帖「{title}」({tid})"),
24
+ )
25
+ print(f"OK 帖子已创建: {tid}")
26
+ print(f" 标题: {title}")
27
+ print(f" 查看: ./board --as <name> thread {tid}")
28
+
29
+
30
+ def cmd_reply(db: BoardDB, identity: str, args: list[str]) -> None:
31
+ name = identity.lower()
32
+ if len(args) < 2:
33
+ print("Usage: ./board --as <name> reply <帖子ID> <内容>")
34
+ raise SystemExit(1)
35
+ tid = args[0]
36
+ body = " ".join(args[1:])
37
+
38
+ full_tid = db.scalar(
39
+ "SELECT id FROM threads WHERE id LIKE ? ESCAPE '\\' LIMIT 1",
40
+ (tid + "%",),
41
+ )
42
+ if not full_tid:
43
+ print(f"ERROR: 帖子 {tid} 不存在")
44
+ raise SystemExit(1)
45
+
46
+ db.execute(
47
+ "INSERT INTO thread_replies(thread_id, author, body) VALUES (?, ?, ?)",
48
+ (full_tid, name, body),
49
+ )
50
+ title = db.scalar("SELECT title FROM threads WHERE id=?", (full_tid,))
51
+ now = ts()
52
+ db.execute(
53
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
54
+ (now, name, f"[BBS] 回帖「{title}」({full_tid})"),
55
+ )
56
+ print(f"OK 回帖成功 (帖子: {full_tid})")
57
+
58
+
59
+ def cmd_thread(db: BoardDB, args: list[str]) -> None:
60
+ if not args:
61
+ print("Usage: ./board thread <帖子ID>")
62
+ raise SystemExit(1)
63
+ tid = args[0]
64
+ full_tid = db.scalar(
65
+ "SELECT id FROM threads WHERE id LIKE ? ESCAPE '\\' LIMIT 1",
66
+ (tid + "%",),
67
+ )
68
+ if not full_tid:
69
+ print(f"ERROR: 帖子 {tid} 不存在")
70
+ raise SystemExit(1)
71
+
72
+ row = db.query_one("SELECT title, author, created_at FROM threads WHERE id=?", (full_tid,))
73
+ title, author, created = row
74
+ print(f"# {title}")
75
+ print(f"> @{author} — {created}\n")
76
+
77
+ for rauthor, rbody, rts in db.query(
78
+ "SELECT author, body, ts FROM thread_replies WHERE thread_id=? ORDER BY id",
79
+ (full_tid,),
80
+ ):
81
+ print("---")
82
+ print(f"> @{rauthor} — {rts}\n")
83
+ print(rbody)
84
+
85
+
86
+ def cmd_threads(db: BoardDB) -> None:
87
+ print("=== BBS 话题列表 ===\n")
88
+ rows = db.query(
89
+ "SELECT t.id, t.title, t.author, t.created_at, "
90
+ "(SELECT COUNT(*) FROM thread_replies r WHERE r.thread_id=t.id) "
91
+ "FROM threads t ORDER BY t.created_at DESC"
92
+ )
93
+ if not rows:
94
+ print(" (暂无话题)\n")
95
+ print(" 发帖: ./board --as <name> post <标题> <内容>")
96
+ else:
97
+ for tid, title, author, date, replies in rows:
98
+ print(f" [{tid}] {title} ({replies} 回帖) by {author} {date}")
99
+ print()
@@ -0,0 +1,161 @@
1
+ """board_bug — bug tracker: report / assign / fix / list / overdue."""
2
+
3
+ import time
4
+ from datetime import datetime
5
+
6
+ from lib.board_db import BoardDB, ts
7
+ from lib.common import is_terminal_bug_status
8
+
9
+
10
+ def cmd_bug(db: BoardDB, identity: str, args: list[str]) -> None:
11
+ subcmd = args[0] if args else "list"
12
+ rest = args[1:] if len(args) > 1 else []
13
+ dispatch = {
14
+ "report": lambda: _bug_report(db, identity, rest),
15
+ "assign": lambda: _bug_assign(db, identity, rest),
16
+ "fix": lambda: _bug_fix(db, identity, rest),
17
+ "list": lambda: _bug_list(db, rest),
18
+ "overdue": lambda: _bug_overdue(db),
19
+ }
20
+ fn = dispatch.get(subcmd)
21
+ if not fn:
22
+ print("Usage: ./board --as <name> bug {report|assign|fix|list|overdue}")
23
+ raise SystemExit(1)
24
+ fn()
25
+
26
+
27
+ def _bug_report(db: BoardDB, identity: str, args: list[str]) -> None:
28
+ name = identity.lower()
29
+ if len(args) < 2:
30
+ print("Usage: ./board --as <name> bug report <P0|P1|P2> <description>")
31
+ raise SystemExit(1)
32
+ severity = args[0].upper()
33
+ if severity not in ("P0", "P1", "P2"):
34
+ print("ERROR: severity must be P0, P1, or P2")
35
+ raise SystemExit(1)
36
+ desc = " ".join(args[1:])
37
+ sla = {"P0": "immediate", "P1": "4h", "P2": "24h"}[severity]
38
+
39
+ now = ts()
40
+
41
+ with db.conn() as c:
42
+ max_id = db.scalar("SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 0) FROM bugs", c=c)
43
+ next_id = f"BUG-{max_id + 1:03d}"
44
+ db.execute(
45
+ "INSERT INTO bugs(id, severity, sla, reporter, status, description) VALUES (?, ?, ?, ?, 'OPEN', ?)",
46
+ (next_id, severity, sla, name, desc),
47
+ c=c,
48
+ )
49
+ msg_id = db.execute(
50
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
51
+ (now, name, f"[{next_id}/{severity}] {desc}"),
52
+ c=c,
53
+ )
54
+ db.deliver_to_inbox(name, "all", msg_id, c=c)
55
+ print(f"OK {next_id} ({severity}) created")
56
+
57
+
58
+ def _bug_assign(db: BoardDB, identity: str, args: list[str]) -> None:
59
+ name = identity.lower()
60
+ if len(args) < 2:
61
+ print("Usage: ./board --as <name> bug assign <BUG-NNN> <session>")
62
+ raise SystemExit(1)
63
+ bugid = args[0].upper()
64
+ if not bugid.startswith("BUG-"):
65
+ bugid = f"BUG-{bugid}"
66
+ assignee = args[1].lower()
67
+
68
+ exists = db.scalar("SELECT COUNT(*) FROM bugs WHERE id=?", (bugid,))
69
+ if not exists:
70
+ print(f"ERROR: {bugid} not found")
71
+ raise SystemExit(1)
72
+
73
+ now = ts()
74
+ with db.conn() as c:
75
+ db.execute("UPDATE bugs SET assignee=?, status='ASSIGNED' WHERE id=?", (assignee, bugid), c=c)
76
+ db.execute(
77
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
78
+ (now, name, assignee, f"[{bugid}] assigned to you"),
79
+ c=c,
80
+ )
81
+ print(f"OK {bugid} assigned to {assignee}")
82
+
83
+
84
+ def _bug_fix(db: BoardDB, identity: str, args: list[str]) -> None:
85
+ name = identity.lower()
86
+ if len(args) < 2:
87
+ print("Usage: ./board --as <name> bug fix <BUG-NNN> <evidence>")
88
+ raise SystemExit(1)
89
+ bugid = args[0].upper()
90
+ if not bugid.startswith("BUG-"):
91
+ bugid = f"BUG-{bugid}"
92
+ evidence = " ".join(args[1:])
93
+
94
+ row = db.query_one("SELECT status FROM bugs WHERE id=?", (bugid,))
95
+ if not row:
96
+ print(f"ERROR: {bugid} not found")
97
+ raise SystemExit(1)
98
+
99
+ if is_terminal_bug_status(row["status"]):
100
+ print(f"Bug {bugid} is already {row['status']}.")
101
+ return
102
+
103
+ now = ts()
104
+ with db.conn() as c:
105
+ db.execute("UPDATE bugs SET status='FIXED', fixed_at=?, evidence=? WHERE id=?", (now, evidence, bugid), c=c)
106
+ db.execute(
107
+ "INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
108
+ (now, name, f"[{bugid}] FIXED — {evidence}"),
109
+ c=c,
110
+ )
111
+ print(f"OK {bugid} marked FIXED")
112
+
113
+
114
+ def _bug_list(db: BoardDB, args: list[str]) -> None:
115
+ filt = args[0] if args else "open"
116
+ print("=== Bug Tracker ===")
117
+
118
+ if filt == "open":
119
+ rows = db.query(
120
+ "SELECT id, severity, status, assignee, reporter, reported_at, description "
121
+ "FROM bugs WHERE status != 'FIXED' ORDER BY reported_at"
122
+ )
123
+ elif filt == "all":
124
+ rows = db.query(
125
+ "SELECT id, severity, status, assignee, reporter, reported_at, description FROM bugs ORDER BY reported_at"
126
+ )
127
+ else:
128
+ rows = db.query(
129
+ "SELECT id, severity, status, assignee, reporter, reported_at, description "
130
+ "FROM bugs WHERE status = ? ORDER BY reported_at",
131
+ (filt.upper(),),
132
+ )
133
+
134
+ if not rows:
135
+ print(f" (no bugs matching filter: {filt})")
136
+ else:
137
+ for bid, sev, status, assignee, reporter, reported, desc in rows:
138
+ print(f"\n {bid} [{sev}] {status}")
139
+ print(f" Reporter: {reporter} Assignee: {assignee or 'unassigned'}")
140
+ print(f" Reported: {reported}")
141
+ print(f" {desc}")
142
+ print()
143
+
144
+
145
+ def _bug_overdue(db: BoardDB) -> None:
146
+ now_epoch = int(time.time())
147
+ rows = db.query("SELECT id, severity, reported_at FROM bugs WHERE status != 'FIXED'")
148
+ found = False
149
+ for bid, sev, reported in rows:
150
+ try:
151
+ dt = datetime.strptime(reported, "%Y-%m-%d %H:%M")
152
+ rep_epoch = int(dt.timestamp())
153
+ except (ValueError, TypeError):
154
+ continue
155
+ elapsed = now_epoch - rep_epoch
156
+ limit = {"P0": 0, "P1": 14400, "P2": 86400}.get(sev, 0)
157
+ if elapsed > limit:
158
+ found = True
159
+ print(f"OVERDUE: {bid} [{sev}] — {elapsed // 60}min since reported")
160
+ if not found:
161
+ print("No overdue bugs.")
@@ -0,0 +1,262 @@
1
+ """board_db — DB connection, helpers, and .md file sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from lib.common import ( # noqa: F401 — re-export ts for board_* modules
12
+ BaseDB,
13
+ ClaudesEnv,
14
+ Signal,
15
+ sanitize_session_name,
16
+ ts,
17
+ )
18
+
19
+ # Emitted when deliver_to_inbox() delivers a message. Session name is the arg.
20
+ # Subscribers (e.g., FileWatcher) can react instantly without polling.
21
+ inbox_delivered = Signal[str]()
22
+
23
+
24
+ class BoardDB(BaseDB):
25
+ """Unified SQLite wrapper — new connection per call, no pooling.
26
+
27
+ Accepts either a ClaudesEnv (production) or a bare Path/str (tests, lightweight callers).
28
+ Adds .md file sync helpers on top of the BaseDB interface.
29
+ """
30
+
31
+ def __init__(self, env_or_path: ClaudesEnv | Path | str):
32
+ if isinstance(env_or_path, ClaudesEnv):
33
+ self.env: ClaudesEnv | None = env_or_path
34
+ self.db_path = env_or_path.board_db
35
+ if not self.db_path.exists():
36
+ print("ERROR: board.db not found. Run: cnb init <session-names>", flush=True)
37
+ raise SystemExit(1)
38
+ # Auto-apply pending schema migrations (idempotent, fast if up-to-date)
39
+ self._auto_migrate()
40
+ else:
41
+ self.env = None
42
+ self.db_path = Path(env_or_path)
43
+
44
+ def _auto_migrate(self) -> None:
45
+ """Apply pending schema migrations on first load (idempotent).
46
+
47
+ Only prints output when migrations were actually applied.
48
+ """
49
+ try:
50
+ from lib.migrate import run_migrations
51
+
52
+ install_home = self.env.install_home if self.env else Path(__file__).resolve().parent.parent
53
+ run_migrations(self.db_path, install_home)
54
+ except SystemExit:
55
+ raise
56
+ except Exception as e:
57
+ print(f"WARNING: migration check failed: {e}", flush=True)
58
+
59
+ @contextmanager
60
+ def conn(self) -> Generator[sqlite3.Connection, None, None]:
61
+ """New connection per call. Commits on success, rolls back on exception."""
62
+ c = sqlite3.connect(str(self.db_path))
63
+ c.execute("PRAGMA journal_mode=WAL")
64
+ c.execute("PRAGMA foreign_keys=ON")
65
+ c.row_factory = sqlite3.Row
66
+ try:
67
+ yield c
68
+ c.commit()
69
+ except Exception:
70
+ c.rollback()
71
+ raise
72
+ finally:
73
+ c.close()
74
+
75
+ def query(
76
+ self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None
77
+ ) -> list[sqlite3.Row]:
78
+ if c is not None:
79
+ return c.execute(sql, params).fetchall()
80
+ with self.conn() as conn:
81
+ return conn.execute(sql, params).fetchall()
82
+
83
+ def query_one(
84
+ self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None
85
+ ) -> sqlite3.Row | None:
86
+ rows = self.query(sql, params, c=c)
87
+ return rows[0] if rows else None
88
+
89
+ def scalar(self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None) -> Any:
90
+ row = self.query_one(sql, params, c=c)
91
+ return row[0] if row else None
92
+
93
+ def execute(self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None) -> int:
94
+ if c is not None:
95
+ return c.execute(sql, params).lastrowid
96
+ with self.conn() as conn:
97
+ cur = conn.execute(sql, params)
98
+ return cur.lastrowid
99
+
100
+ def execute_changes(self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None) -> int:
101
+ if c is not None:
102
+ c.execute(sql, params)
103
+ return c.execute("SELECT changes()").fetchone()[0]
104
+ with self.conn() as conn:
105
+ conn.execute(sql, params)
106
+ return conn.execute("SELECT changes()").fetchone()[0]
107
+
108
+ def ensure_session(self, name: str, *, c: sqlite3.Connection | None = None) -> None:
109
+ n = name.lower()
110
+ existing = self.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (n,), c=c)
111
+ if existing == 0:
112
+ self.execute("INSERT INTO sessions(name) VALUES (?)", (n,), c=c)
113
+
114
+ # --- .md file sync ---
115
+ #
116
+ # All writes use temp-file + atomic rename to avoid corruption on
117
+ # crash mid-write. Section detection is strict: ## heading must be
118
+ # followed by space or end-of-line (not e.g. "## @收件箱-extra").
119
+
120
+ INBOX_HEADINGS: tuple[str, ...] = ("## @inbox", "## @收件箱")
121
+ TASK_HEADINGS: tuple[str, ...] = ("## Current task", "## 当前任务")
122
+
123
+ @staticmethod
124
+ def _replace_section(
125
+ text: str,
126
+ headings: tuple[str, ...],
127
+ replacement: str | None,
128
+ ) -> str | None:
129
+ """Replace or remove the content of a named Markdown section.
130
+
131
+ If *replacement* is None, the section is removed. Returns the new
132
+ text, or None if the section was not found and no append is needed.
133
+
134
+ Heading detection: a line is a section boundary if it starts with "## "
135
+ (after stripping leading whitespace). Target headings are matched
136
+ case-insensitively against the normalized prefix.
137
+ """
138
+ lines = text.split("\n")
139
+ out: list[str] = []
140
+ in_section = False
141
+ found = False
142
+
143
+ normalized_headings = tuple(h.strip().lower() for h in headings)
144
+
145
+ for line in lines:
146
+ stripped = line.strip().lower()
147
+ if any(stripped == h or stripped.startswith(h + " ") for h in normalized_headings):
148
+ found = True
149
+ in_section = True
150
+ if replacement is not None:
151
+ out.append(line)
152
+ out.append(replacement)
153
+ continue
154
+ if in_section and stripped.startswith("## "):
155
+ in_section = False
156
+ out.append("")
157
+ out.append(line)
158
+ continue
159
+ if in_section:
160
+ continue
161
+ out.append(line)
162
+
163
+ if not found:
164
+ return None
165
+ return "\n".join(out)
166
+
167
+ def _atomic_write(self, path: Path, content: str) -> None:
168
+ """Write *content* to *path* atomically (temp + rename)."""
169
+ tmp = path.with_suffix(path.suffix + ".tmp")
170
+ try:
171
+ tmp.write_text(content)
172
+ tmp.replace(path)
173
+ except OSError:
174
+ pass
175
+
176
+ # ── inbox sync ──
177
+
178
+ def sync_inbox_to_file(self, target: str, *, c: sqlite3.Connection | None = None) -> None:
179
+ """Sync unread inbox messages to {target}.md file.
180
+
181
+ Accepts an optional *c* to use the caller's connection (so that
182
+ uncommitted inbox rows are visible within a transaction).
183
+ """
184
+ if not self.env:
185
+ return
186
+ sf = self.env.sessions_dir / f"{target}.md"
187
+ if not sf.exists():
188
+ return
189
+ rows = self.query(
190
+ "SELECT '- [' || m.ts || '] **' || m.sender || '**: ' || substr(m.body, 1, 60) "
191
+ "FROM inbox i JOIN messages m ON i.message_id=m.id "
192
+ "WHERE i.session=? AND i.read=0 ORDER BY m.ts",
193
+ (target,),
194
+ c=c,
195
+ )
196
+ inbox_lines = "\n".join(r[0] for r in rows) if rows else ""
197
+
198
+ try:
199
+ text = sf.read_text()
200
+ except OSError:
201
+ return
202
+
203
+ result = self._replace_section(text, self.INBOX_HEADINGS, inbox_lines or None)
204
+ if result is not None:
205
+ self._atomic_write(sf, result)
206
+ elif inbox_lines:
207
+ heading = self.INBOX_HEADINGS[1] # canonical: Chinese heading
208
+ self._atomic_write(sf, text.rstrip("\n") + f"\n\n{heading}\n{inbox_lines}\n")
209
+
210
+ def clear_inbox_file(self, target: str) -> None:
211
+ if not self.env:
212
+ return
213
+ sf = self.env.sessions_dir / f"{target}.md"
214
+ if not sf.exists():
215
+ return
216
+ try:
217
+ text = sf.read_text()
218
+ except OSError:
219
+ return
220
+ result = self._replace_section(text, self.INBOX_HEADINGS, None)
221
+ if result is not None:
222
+ self._atomic_write(sf, result)
223
+
224
+ # ── status sync ──
225
+
226
+ def sync_status_to_file(self, target: str, status: str) -> None:
227
+ if not self.env:
228
+ return
229
+ sf = self.env.sessions_dir / f"{target}.md"
230
+ if not sf.exists():
231
+ return
232
+ try:
233
+ text = sf.read_text()
234
+ except OSError:
235
+ return
236
+ result = self._replace_section(text, self.TASK_HEADINGS, status)
237
+ if result is not None:
238
+ self._atomic_write(sf, result)
239
+
240
+ def deliver_to_inbox(
241
+ self, sender: str, recipient: str, msg_id: int, *, c: sqlite3.Connection | None = None
242
+ ) -> None:
243
+ if recipient == "all":
244
+ sessions = self.query("SELECT name FROM sessions WHERE name != ?", (sender,), c=c)
245
+ for (target,) in sessions:
246
+ self.execute(
247
+ "INSERT INTO inbox(session, message_id) VALUES (?, ?)",
248
+ (target, msg_id),
249
+ c=c,
250
+ )
251
+ for (target,) in sessions:
252
+ self.sync_inbox_to_file(target, c=c)
253
+ inbox_delivered.emit(target)
254
+ else:
255
+ self.ensure_session(recipient, c=c)
256
+ self.execute(
257
+ "INSERT INTO inbox(session, message_id) VALUES (?, ?)",
258
+ (recipient, msg_id),
259
+ c=c,
260
+ )
261
+ self.sync_inbox_to_file(recipient, c=c)
262
+ inbox_delivered.emit(recipient)