brahman-buddy 0.2.6__tar.gz

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 (51) hide show
  1. brahman_buddy-0.2.6/LICENSE +21 -0
  2. brahman_buddy-0.2.6/PKG-INFO +10 -0
  3. brahman_buddy-0.2.6/pyproject.toml +28 -0
  4. brahman_buddy-0.2.6/pyproject.toml.orig +31 -0
  5. brahman_buddy-0.2.6/src/work_brahman/__init__.py +1 -0
  6. brahman_buddy-0.2.6/src/work_brahman/__main__.py +7 -0
  7. brahman_buddy-0.2.6/src/work_brahman/asks.py +125 -0
  8. brahman_buddy-0.2.6/src/work_brahman/bitable.py +251 -0
  9. brahman_buddy-0.2.6/src/work_brahman/bot.py +375 -0
  10. brahman_buddy-0.2.6/src/work_brahman/channel.py +101 -0
  11. brahman_buddy-0.2.6/src/work_brahman/cli.py +647 -0
  12. brahman_buddy-0.2.6/src/work_brahman/configure.py +282 -0
  13. brahman_buddy-0.2.6/src/work_brahman/duties/__init__.py +11 -0
  14. brahman_buddy-0.2.6/src/work_brahman/duties/code_review/DUTY.md +75 -0
  15. brahman_buddy-0.2.6/src/work_brahman/duties/code_review/__init__.py +266 -0
  16. brahman_buddy-0.2.6/src/work_brahman/duties/code_review/forge.py +255 -0
  17. brahman_buddy-0.2.6/src/work_brahman/duties/code_review/rebase.py +125 -0
  18. brahman_buddy-0.2.6/src/work_brahman/duties/threads/DUTY.md +67 -0
  19. brahman_buddy-0.2.6/src/work_brahman/duties/threads/__init__.py +155 -0
  20. brahman_buddy-0.2.6/src/work_brahman/engine.py +170 -0
  21. brahman_buddy-0.2.6/src/work_brahman/families/__init__.py +18 -0
  22. brahman_buddy-0.2.6/src/work_brahman/families/long_story/FAMILY.md +70 -0
  23. brahman_buddy-0.2.6/src/work_brahman/families/long_story/__init__.py +91 -0
  24. brahman_buddy-0.2.6/src/work_brahman/families/monthly_report/FAMILY.md +49 -0
  25. brahman_buddy-0.2.6/src/work_brahman/families/monthly_report/__init__.py +208 -0
  26. brahman_buddy-0.2.6/src/work_brahman/families/short_story/FAMILY.md +78 -0
  27. brahman_buddy-0.2.6/src/work_brahman/families/short_story/__init__.py +79 -0
  28. brahman_buddy-0.2.6/src/work_brahman/families/upstream_mr/FAMILY.md +62 -0
  29. brahman_buddy-0.2.6/src/work_brahman/families/upstream_mr/__init__.py +109 -0
  30. brahman_buddy-0.2.6/src/work_brahman/feishu.py +247 -0
  31. brahman_buddy-0.2.6/src/work_brahman/install.py +519 -0
  32. brahman_buddy-0.2.6/src/work_brahman/long_stories.py +502 -0
  33. brahman_buddy-0.2.6/src/work_brahman/meter.py +58 -0
  34. brahman_buddy-0.2.6/src/work_brahman/portable.py +118 -0
  35. brahman_buddy-0.2.6/src/work_brahman/procs.py +52 -0
  36. brahman_buddy-0.2.6/src/work_brahman/py.typed +0 -0
  37. brahman_buddy-0.2.6/src/work_brahman/runs.py +194 -0
  38. brahman_buddy-0.2.6/src/work_brahman/scheduler.py +252 -0
  39. brahman_buddy-0.2.6/src/work_brahman/short_stories.py +651 -0
  40. brahman_buddy-0.2.6/src/work_brahman/skills/pr-review/SKILL.md +38 -0
  41. brahman_buddy-0.2.6/src/work_brahman/skills/work-and-ask/SKILL.md +77 -0
  42. brahman_buddy-0.2.6/src/work_brahman/static/index.html +630 -0
  43. brahman_buddy-0.2.6/src/work_brahman/static/media/brahman-01-evolution.jpg +0 -0
  44. brahman_buddy-0.2.6/src/work_brahman/static/media/brahman-01-evolution.mp4 +0 -0
  45. brahman_buddy-0.2.6/src/work_brahman/static/media/brahman-02-system.jpg +0 -0
  46. brahman_buddy-0.2.6/src/work_brahman/static/media/brahman-02-system.mp4 +0 -0
  47. brahman_buddy-0.2.6/src/work_brahman/static/tokens.css +45 -0
  48. brahman_buddy-0.2.6/src/work_brahman/telegram.py +402 -0
  49. brahman_buddy-0.2.6/src/work_brahman/text.py +811 -0
  50. brahman_buddy-0.2.6/src/work_brahman/topics.py +86 -0
  51. brahman_buddy-0.2.6/src/work_brahman/web.py +402 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cong Zhang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: brahman-buddy
3
+ Version: 0.2.6
4
+ Summary: A personal work buddy: Telegram, Lark and Feishu chats driving Claude Code, with scheduled jobs, short and long stories, and portable skills
5
+ Author: Cong Zhang
6
+ Author-email: Cong Zhang <13283869+congzhangzh@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: lark-oapi>=1.7.5
10
+ Requires-Python: >=3.12
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "brahman-buddy"
3
+ version = "0.2.6"
4
+ license = "MIT"
5
+ license-files = ["LICENSE"]
6
+ description = "A personal work buddy: Telegram, Lark and Feishu chats driving Claude Code, with scheduled jobs, short and long stories, and portable skills"
7
+ requires-python = ">=3.12"
8
+ dependencies = ["lark-oapi>=1.7.5"]
9
+
10
+ [[project.authors]]
11
+ name = "Cong Zhang"
12
+ email = "13283869+congzhangzh@users.noreply.github.com"
13
+
14
+ [project.scripts]
15
+ buddy = "work_brahman.cli:main"
16
+
17
+ [tool.uv.build-backend]
18
+ module-name = "work_brahman"
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.11.6,<0.12.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [dependency-groups]
28
+ dev = ["pytest>=9.1.1"]
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "brahman-buddy"
3
+ version = "0.2.6"
4
+ license = "MIT"
5
+ license-files = ["LICENSE"]
6
+ description = "A personal work buddy: Telegram, Lark and Feishu chats driving Claude Code, with scheduled jobs, short and long stories, and portable skills"
7
+ authors = [
8
+ { name = "Cong Zhang", email = "13283869+congzhangzh@users.noreply.github.com" }
9
+ ]
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "lark-oapi>=1.7.5",
13
+ ]
14
+
15
+ [project.scripts]
16
+ buddy = "work_brahman.cli:main"
17
+
18
+ [tool.uv.build-backend]
19
+ module-name = "work_brahman" # the code keeps its name; the package on PyPI is brahman-buddy
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.11.6,<0.12.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pytest>=9.1.1",
28
+ ]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """work_brahman: a small, extensible work buddy (Feishu/Lark + Claude Code)."""
@@ -0,0 +1,7 @@
1
+ """python -m work_brahman: the same as the buddy command (cli.main)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,125 @@
1
+ """Asks: a question with buttons, sent to every chat of one person; the first answer wins.
2
+
3
+ A job or duty runs as a child process that ends long before anyone answers, while the
4
+ answer arrives in `buddy serve`. So an ask is a small file both can read:
5
+
6
+ $XDG_STATE_HOME/work_brahman/asks/<ask id>.json
7
+
8
+ The child posts the card to each linked chat and writes what to do for each choice
9
+ (`then`: a job or duty to run). When someone presses a button in any app, serve marks the
10
+ first answer, changes every copy to "answered on Telegram: …", and starts what the choice
11
+ asked for. A later press on another copy is told it was already answered. STOP closes
12
+ every ask still open.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import secrets
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+
22
+ from .channel import Channel
23
+ from .text import app, t
24
+
25
+ _lock = threading.Lock() # one answer at a time, in serve
26
+
27
+
28
+ def create(state: Path, channels: list[Channel], chats: list[str], markdown: str, *,
29
+ title: str, options: list[list[str]], then: dict | None = None, run: str = "",
30
+ failed: bool = False, note: str = "") -> dict:
31
+ """Post the question to every chat. options: [[key, label], ...]; then: key -> [kind, name]."""
32
+ ask = {"id": f"{time.strftime('%m%d%H%M')}{secrets.token_hex(2)}", "run": run, "title": title,
33
+ "markdown": markdown, "options": options, "then": then or {}, "failed": failed,
34
+ "note": note, "asked": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "copies": {},
35
+ "answer": None}
36
+ for chat in chats:
37
+ ch = next((c for c in channels if c.owns(chat)), None)
38
+ handle = ch.ask(chat, markdown, title=title, options=options, ask_id=ask["id"],
39
+ failed=failed, note=note) if ch else None
40
+ if handle:
41
+ ask["copies"][chat] = handle
42
+ _save(state, ask)
43
+ return ask
44
+
45
+
46
+ def answer(state: Path, channels: list[Channel], ask_id: str, choice: str, where: str,
47
+ who: str) -> tuple[str, dict | None]:
48
+ """Record the first answer and settle every copy. Returns (what to tell the presser,
49
+ the ask if this answer won, else None)."""
50
+ with _lock:
51
+ ask = load(state, ask_id)
52
+ if ask is None:
53
+ return t("ask.unknown"), None
54
+ if ask["answer"]:
55
+ a = ask["answer"]
56
+ return t("ask.already", said=_said(a)), None
57
+ label = dict(map(tuple, ask["options"])).get(choice)
58
+ if label is None:
59
+ return t("ask.bad_choice"), None
60
+ ask["answer"] = {"choice": choice, "label": label, "where": where, "who": who,
61
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
62
+ _save(state, ask)
63
+ _settle_all(channels, ask, t("ask.settled", where=app(where), label=label))
64
+ return f"✓ {label}", ask
65
+
66
+
67
+ def stop_all(state: Path, channels: list[Channel], where: str, who: str) -> int:
68
+ """Close every ask still open, as STOP does. Returns how many."""
69
+ closed = []
70
+ with _lock:
71
+ for f in sorted((state / "asks").glob("*.json")):
72
+ if time.time() - f.stat().st_mtime > 14 * 86400: # Lark can't edit it any more
73
+ continue
74
+ ask = load(state, f.stem)
75
+ if ask and not ask["answer"]:
76
+ ask["answer"] = {"choice": "", "label": t("ask.stopped_label"), "where": where, "who": who,
77
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "stopped": True}
78
+ _save(state, ask)
79
+ closed.append(ask)
80
+ for ask in closed:
81
+ _settle_all(channels, ask, t("ask.stopped_line", where=app(where)))
82
+ return len(closed)
83
+
84
+
85
+ def still_open(state: Path, title: str) -> dict | None:
86
+ """The unanswered ask with this title, if there is one: the same question is asked once,
87
+ and its card stands until it is answered."""
88
+ for f in sorted((state / "asks").glob("*.json"), reverse=True):
89
+ if time.time() - f.stat().st_mtime > 14 * 86400: # as in stop_all: past editing
90
+ continue
91
+ ask = load(state, f.stem)
92
+ if ask and not ask["answer"] and ask["title"] == title:
93
+ return ask
94
+ return None
95
+
96
+
97
+ def load(state: Path, ask_id: str) -> dict | None:
98
+ if not ask_id.isalnum(): # it came from a button press: never a path
99
+ return None
100
+ try:
101
+ return json.loads((state / "asks" / f"{ask_id}.json").read_text())
102
+ except (OSError, ValueError):
103
+ return None
104
+
105
+
106
+ def _said(a: dict) -> str:
107
+ return (t("ask.stopped_from", where=app(a["where"])) if a.get("stopped")
108
+ else t("ask.answered_on", where=app(a["where"]), label=a["label"]))
109
+
110
+
111
+ def _settle_all(channels: list[Channel], ask: dict, line: str) -> None:
112
+ """Every copy loses its buttons and says how the question was settled."""
113
+ for chat, handle in ask["copies"].items():
114
+ ch = next((c for c in channels if c.owns(chat)), None)
115
+ if ch:
116
+ ch.settle(chat, handle, ask["markdown"], title=ask["title"], line=line,
117
+ failed=ask["failed"], note=ask["note"])
118
+
119
+
120
+ def _save(state: Path, ask: dict) -> None:
121
+ d = state / "asks"
122
+ d.mkdir(parents=True, exist_ok=True)
123
+ tmp = d / f"{ask['id']}.tmp"
124
+ tmp.write_text(json.dumps(ask, indent=1, ensure_ascii=False))
125
+ os.replace(tmp, d / f"{ask['id']}.json")
@@ -0,0 +1,251 @@
1
+ """A Lark/Feishu Base (Bitable) as a workbench: the buddy's runs, asks, PRs, threads,
2
+ workflows and short stories as tables, for Lark's own charts and dashboards.
3
+
4
+ One way only, buddy -> Base. Every minute (and on `buddy bitable`) the rows are rebuilt
5
+ from the files the buddy already writes (run records, asks, code-review.json,
6
+ threads.json), and only rows that changed are sent: new ones created, changed ones
7
+ updated, by a key per row. What was sent is remembered in state/bitable.json:
8
+
9
+ {"app": token, "url": ..., "tables": {name: table id}, "rows": {name: {key: [record id, hash]}}}
10
+
11
+ On the first sync the buddy creates the Base in its own space and makes the person in
12
+ [bitable] share_with a full-access collaborator. Needs the app scopes bitable:app and
13
+ docs:permission.member:create. Rows never leave the tenant; the Base is as private as
14
+ any document the bot owns.
15
+ """
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import threading
21
+ import time
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+
25
+ import lark_oapi as lark
26
+
27
+ from . import short_stories as shorts
28
+ from .runs import read as read_run
29
+
30
+ log = logging.getLogger("buddy.bitable")
31
+
32
+ STAMP = "%Y-%m-%dT%H:%M:%S%z"
33
+ TEXT, NUMBER, SELECT, DATETIME, URL = 1, 2, 3, 5, 15
34
+ # table -> [(field, type)], the first field is the row's key (the primary field)
35
+ TABLES = {
36
+ "Runs": [("Run", TEXT), ("Workflow", TEXT), ("Kind", SELECT), ("Status", SELECT),
37
+ ("Started", DATETIME), ("Seconds", NUMBER), ("USD (list)", NUMBER), ("Tokens", NUMBER),
38
+ ("Denied", NUMBER), ("Trigger", TEXT), ("Result", TEXT), ("Version", TEXT)],
39
+ "Asks": [("Ask", TEXT), ("Title", TEXT), ("Question", TEXT), ("Options", TEXT),
40
+ ("Answer", TEXT), ("Where", SELECT), ("Asked", DATETIME), ("Answered", DATETIME),
41
+ ("Minutes to answer", NUMBER)],
42
+ "PRs": [("PR", TEXT), ("Link", URL), ("Repo", TEXT), ("Title", TEXT), ("CI", SELECT),
43
+ ("Review", SELECT), ("Merge", SELECT), ("Last comment", DATETIME)],
44
+ "Threads": [("Thread", TEXT), ("Link", URL), ("Kind", SELECT), ("Repo", TEXT),
45
+ ("Title", TEXT), ("Turn", SELECT), ("Last by", TEXT), ("At", DATETIME),
46
+ ("Mine", NUMBER)],
47
+ "Workflows": [("Workflow", TEXT), ("Schedule", TEXT), ("Runs (3 days)", NUMBER),
48
+ ("Failed (3 days)", NUMBER), ("USD (3 days)", NUMBER),
49
+ ("Tokens (3 days)", NUMBER), ("Last run", DATETIME), ("Last status", SELECT)],
50
+ # Short stories (short_stories.py): sessions' pipeline-bound work, one row per story
51
+ "Short stories": [("Story", TEXT), ("Title", TEXT), ("Status", SELECT), ("Who", TEXT),
52
+ ("Started", DATETIME), ("Ended", DATETIME), ("Minutes", NUMBER),
53
+ ("Marks done", NUMBER), ("Marks", NUMBER), ("Not planned", NUMBER),
54
+ ("Replies", NUMBER), ("Stop from", SELECT), ("Last event", TEXT)],
55
+ }
56
+ FAILED = ("failed", "timeout", "interrupted")
57
+
58
+
59
+ def _ms(stamp: str | None) -> int | None:
60
+ if not stamp:
61
+ return None
62
+ for fmt in (STAMP, "%Y-%m-%dT%H:%M:%SZ"): # ours carry an offset; GitHub's end in Z (UTC)
63
+ try:
64
+ d = datetime.strptime(stamp, fmt)
65
+ return int((d if d.tzinfo else d.replace(tzinfo=UTC)).timestamp() * 1000)
66
+ except ValueError:
67
+ continue
68
+ return None
69
+
70
+
71
+ def _read(path: Path) -> dict:
72
+ try:
73
+ return json.loads(path.read_text())
74
+ except (OSError, ValueError):
75
+ return {}
76
+
77
+
78
+ def rows(state: Path, cfg: dict, days: int = 30) -> dict[str, dict[str, dict]]:
79
+ """Every table's rows, {table: {key: fields}}, from the buddy's own files."""
80
+ out = {name: {} for name in TABLES}
81
+ runs = []
82
+ for d in sorted((state / "runs").glob("*"))[-days:]:
83
+ runs += [read_run(r) for r in (_read(f) for f in d.glob("*.json")) if r.get("id")]
84
+ for r in runs:
85
+ f = r.get("fitness") or {}
86
+ out["Runs"][r["id"]] = {
87
+ "Workflow": f"{r['kind']}:{r['name']}", "Kind": r["kind"], "Status": r["status"],
88
+ "Started": _ms(r.get("started")), "Seconds": f.get("seconds"),
89
+ "USD (list)": round(f.get("usd_list") or 0, 4),
90
+ "Tokens": sum(f.get(k) or 0 for k in ("tokens_in", "tokens_cached", "tokens_out")),
91
+ "Denied": f.get("denied") or 0, "Trigger": r.get("trigger", ""),
92
+ "Result": str(r.get("result") or r.get("error") or "")[:500], "Version": r.get("version", "")}
93
+ for f in (state / "asks").glob("*.json"):
94
+ a = _read(f)
95
+ if not a.get("id"):
96
+ continue
97
+ ans = a.get("answer") or {}
98
+ asked, answered = _ms(a.get("asked")), _ms(ans.get("at"))
99
+ out["Asks"][a["id"]] = {
100
+ "Title": a.get("title", ""), "Question": a.get("markdown", "")[:1000],
101
+ "Options": " / ".join(label for _, label in a.get("options", [])),
102
+ "Answer": ans.get("label", ""), "Where": ans.get("where", "") or "open",
103
+ "Asked": asked, "Answered": answered,
104
+ "Minutes to answer": round((answered - asked) / 60000, 1) if asked and answered else None}
105
+ for pr in _read(state / "code-review.json").get("prs", {}).values():
106
+ talk = pr.get("talk") or []
107
+ out["PRs"][pr["key"]] = {
108
+ "Link": {"text": pr["url"], "link": pr["url"]}, "Repo": pr["repo"], "Title": pr["title"],
109
+ "CI": pr["ci"], "Review": pr["review"], "Merge": pr["merge"],
110
+ "Last comment": _ms(talk[-1]["at"]) if talk else None}
111
+ for url, t in _read(state / "threads.json").get("items", {}).items():
112
+ out["Threads"][f"{t['repo']}#{t['number']}"] = {
113
+ "Link": {"text": url, "link": url}, "Kind": t["kind"], "Repo": t["repo"],
114
+ "Title": t["title"], "Turn": t["turn"], "Last by": t.get("last") or "",
115
+ "At": _ms(t.get("at")), "Mine": t.get("mine") or 0}
116
+ since = time.time() - 3 * 86400
117
+ for kind, section in (("job", "jobs"), ("duty", "duties")):
118
+ for name, t in cfg.get(section, {}).items():
119
+ rs = [r for r in runs if r["kind"] == kind and r["name"] == name]
120
+ recent = [r for r in rs if (_ms(r.get("started")) or 0) / 1000 >= since]
121
+ last = max(rs, key=lambda r: r.get("started", ""), default=None)
122
+ fit = [r.get("fitness") or {} for r in recent]
123
+ out["Workflows"][f"{kind}:{name}"] = {
124
+ "Schedule": t.get("schedule", "not scheduled"), "Runs (3 days)": len(recent),
125
+ "Failed (3 days)": sum(r["status"] in FAILED for r in recent),
126
+ "USD (3 days)": round(sum(f.get("usd_list") or 0 for f in fit), 4),
127
+ "Tokens (3 days)": sum(f.get(k) or 0 for f in fit for k in ("tokens_in", "tokens_cached", "tokens_out")),
128
+ "Last run": _ms(last.get("started")) if last else None,
129
+ "Last status": last["status"] if last else "never"}
130
+ # A family is a workflow too, and its stories are its runs (the web's level 1 does the same).
131
+ now = int(time.time() * 1000)
132
+ for s in shorts.all_stories(state):
133
+ status = "stopped" if s["status"] == "failed" and s.get("stop") else s["status"]
134
+ started, ended = _ms(s["started"]), _ms(s["ended"])
135
+ out["Short stories"][f"#{s['id']}"] = {
136
+ "Title": s["title"], "Status": status, "Who": s.get("who") or "", "Started": started,
137
+ "Ended": ended, "Minutes": round(((ended or now) - started) / 60000, 1) if started else None,
138
+ "Marks done": sum(m["status"] in ("done", "skipped") for m in s["marks"]),
139
+ "Marks": len(s["marks"]), "Not planned": sum(not m.get("planned", True) for m in s["marks"]),
140
+ "Replies": len(s.get("replies", [])), "Stop from": (s.get("stop") or {}).get("where"),
141
+ "Last event": s["events"][-1]["text"][:500] if s["events"] else ""}
142
+ recent = [r for r in out["Short stories"].values() if (r.get("Started") or 0) / 1000 >= since]
143
+ last = max(out["Short stories"].values(), key=lambda r: r.get("Started") or 0, default=None)
144
+ out["Workflows"]["family:short-story"] = {
145
+ "Schedule": "started by sessions", "Runs (3 days)": len(recent),
146
+ "Failed (3 days)": sum(r["Status"] in ("failed", "stopped") for r in recent),
147
+ "USD (3 days)": 0, "Tokens (3 days)": 0, # the buddy only keeps the board: no agent of its own
148
+ "Last run": last.get("Started") if last else None,
149
+ "Last status": last["Status"] if last else "never"}
150
+ # Lark rejects null values; an empty cell is simply left out
151
+ return {t: {k: {f: v for f, v in fields.items() if v is not None} for k, fields in rs.items()}
152
+ for t, rs in out.items()}
153
+
154
+
155
+ class Bitable:
156
+ def __init__(self, app_id: str, app_secret: str, domain, state: Path, cfg: dict):
157
+ self.api = (lark.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain)
158
+ .log_level(lark.LogLevel.WARNING).build())
159
+ self.state, self.cfg = state, cfg
160
+ self.share_with = cfg.get("bitable", {}).get("share_with", "")
161
+ self.path = state / "bitable.json"
162
+ self.lock = threading.Lock()
163
+
164
+ def start(self, every: int = 60) -> None:
165
+ def loop():
166
+ while True:
167
+ try:
168
+ n = self.sync()
169
+ if n:
170
+ log.info("bitable: %d rows sent", n)
171
+ except Exception:
172
+ log.exception("bitable sync failed")
173
+ time.sleep(every)
174
+ threading.Thread(target=loop, name="bitable", daemon=True).start()
175
+
176
+ def sync(self) -> int:
177
+ """Create the Base on the first call, then send what changed. Returns rows sent."""
178
+ with self.lock:
179
+ st = _read(self.path) or {"rows": {}}
180
+ if not st.get("app"):
181
+ self._create(st)
182
+ self._save(st)
183
+ for name in TABLES.keys() - st["tables"].keys(): # added since the Base was made
184
+ self._table(st, name)
185
+ self._save(st)
186
+ sent = 0
187
+ for table, want in rows(self.state, self.cfg).items():
188
+ have = st["rows"].setdefault(table, {})
189
+ new, changed = [], []
190
+ for key, fields in want.items():
191
+ fields = {TABLES[table][0][0]: key, **fields}
192
+ h = hashlib.sha1(json.dumps(fields, sort_keys=True).encode()).hexdigest()[:12]
193
+ if key not in have:
194
+ new.append((key, fields, h))
195
+ elif have[key][1] != h:
196
+ changed.append((key, fields, h))
197
+ tid = st["tables"][table]
198
+ for i in range(0, len(new), 500):
199
+ part = new[i:i + 500]
200
+ made = self._call("POST", f"/apps/{st['app']}/tables/{tid}/records/batch_create",
201
+ {"records": [{"fields": f} for _, f, _ in part]})["records"]
202
+ for (key, _, h), rec in zip(part, made):
203
+ have[key] = [rec["record_id"], h]
204
+ for i in range(0, len(changed), 500):
205
+ part = changed[i:i + 500]
206
+ self._call("POST", f"/apps/{st['app']}/tables/{tid}/records/batch_update",
207
+ {"records": [{"record_id": have[k][0], "fields": f} for k, f, _ in part]})
208
+ for key, _, h in part:
209
+ have[key][1] = h
210
+ sent += len(new) + len(changed)
211
+ self._save(st)
212
+ return sent
213
+
214
+ def url(self) -> str:
215
+ return _read(self.path).get("url", "")
216
+
217
+ def _create(self, st: dict) -> None:
218
+ app = self._call("POST", "/apps", {"name": "brahman workbench"})["app"]
219
+ st.update(app=app["app_token"], url=app.get("url", ""), tables={}, rows={})
220
+ for name in TABLES:
221
+ self._table(st, name)
222
+ if app.get("default_table_id"): # the empty table every new Base starts with
223
+ self._call("DELETE", f"/apps/{app['app_token']}/tables/{app['default_table_id']}", None)
224
+ if self.share_with:
225
+ self._call("POST", f"/permissions/{app['app_token']}/members?type=bitable&need_notification=true",
226
+ {"member_type": "openid", "member_id": self.share_with, "perm": "full_access"},
227
+ base="/open-apis/drive/v1")
228
+ log.info("bitable: created %s", st["url"])
229
+
230
+ def _table(self, st: dict, name: str) -> None:
231
+ t = self._call("POST", f"/apps/{st['app']}/tables", {"table": {
232
+ "name": name, "default_view_name": "All",
233
+ "fields": [{"field_name": f, "type": kind} for f, kind in TABLES[name]]}})
234
+ st["tables"][name] = t["table_id"]
235
+ log.info("bitable: table %s added", name)
236
+
237
+ def _call(self, method: str, path: str, body, base: str = "/open-apis/bitable/v1") -> dict:
238
+ req = (lark.BaseRequest.builder().http_method(getattr(lark.HttpMethod, method))
239
+ .uri(base + path).token_types({lark.AccessTokenType.TENANT}))
240
+ if body is not None:
241
+ req = req.body(body)
242
+ resp = self.api.request(req.build())
243
+ d = json.loads(resp.raw.content or b"{}")
244
+ if d.get("code"):
245
+ raise RuntimeError(f"lark {method} {path.split('?')[0]}: {d.get('code')} {d.get('msg')}")
246
+ return d.get("data") or {}
247
+
248
+ def _save(self, st: dict) -> None:
249
+ tmp = self.path.with_suffix(".tmp")
250
+ tmp.write_text(json.dumps(st, ensure_ascii=False))
251
+ tmp.replace(self.path)