Skip to content

Commit 3476cef

Browse files
authored
Merge pull request #18 from dmoliveira/my_opencode-session-digest
Add session digest command and optional exit hook wrapper
2 parents 147df7f + 00efe6d commit 3476cef

File tree

7 files changed

+260
-2
lines changed

7 files changed

+260
-2
lines changed

Makefile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ install-test: ## Run installer smoke test in temp HOME
2424
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/mcp_command.py" status; \
2525
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" profile lean; \
2626
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" doctor --json; \
27-
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/notify_command.py" status
27+
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/notify_command.py" status; \
28+
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/session_digest.py" run --reason install-test; \
29+
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/session_digest.py" show
2830

2931
release: ## Create and publish release (VERSION=0.1.1)
3032
@test -n "$(VERSION)" || (echo "VERSION is required, eg: make release VERSION=0.1.1" && exit 2)

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This repo gives you a clean, portable OpenCode setup with fast MCP controls insi
1717
- 🧠 Built-in `/mcp` command for `status`, `enable`, and `disable`.
1818
- 🎛️ Built-in `/plugin` command to enable or disable plugins without editing JSON.
1919
- 🔔 Built-in `/notify` command to tune notification behavior by level (all, channel, event, per-channel event).
20+
- 🧾 Built-in `/digest` command for session snapshots and optional exit hooks.
2021
- 💸 Better token control by enabling `context7` / `gh_grep` only on demand.
2122
- 🔒 Autonomous-friendly permissions for trusted project paths.
2223
- 🔁 Easy updates by rerunning the installer.
@@ -195,12 +196,43 @@ Autocomplete-friendly shortcuts:
195196
- event: `events.<type>`
196197
- per-event channel: `channels.<type>.sound|visual`
197198

199+
## Session digest inside OpenCode 🧾
200+
201+
Use these directly in OpenCode:
202+
203+
```text
204+
/digest run --reason manual
205+
/digest show
206+
```
207+
208+
Autocomplete-friendly shortcuts:
209+
210+
```text
211+
/digest-run
212+
/digest-show
213+
```
214+
215+
The digest command writes to `~/.config/opencode/digests/last-session.json` by default.
216+
217+
For automatic digest-on-exit behavior (including `Ctrl+C`), launch OpenCode through:
218+
219+
```bash
220+
~/.config/opencode/my_opencode/scripts/opencode_session.sh
221+
```
222+
223+
Optional environment variables:
224+
- `MY_OPENCODE_DIGEST_PATH` custom output path
225+
- `MY_OPENCODE_DIGEST_HOOK` command to run after digest is written
226+
- `DIGEST_REASON_ON_EXIT` custom reason label (default `exit`)
227+
198228
## Repo layout 📦
199229

200230
- `opencode.json` - global OpenCode config (linked to default path)
201231
- `scripts/mcp_command.py` - backend script for `/mcp`
202232
- `scripts/plugin_command.py` - backend script for `/plugin`
203233
- `scripts/notify_command.py` - backend script for `/notify`
234+
- `scripts/session_digest.py` - backend script for `/digest`
235+
- `scripts/opencode_session.sh` - optional wrapper to run digest on process exit
204236
- `install.sh` - one-step installer/updater
205237
- `Makefile` - common maintenance commands (`make help`)
206238
- `.github/workflows/ci.yml` - CI checks and installer smoke test

install.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,15 @@ if [ -n "$REPO_REF" ]; then
6060
git -C "$INSTALL_DIR" checkout "$REPO_REF"
6161
fi
6262

63-
chmod +x "$INSTALL_DIR/scripts/mcp_command.py" "$INSTALL_DIR/scripts/plugin_command.py" "$INSTALL_DIR/scripts/notify_command.py"
63+
chmod +x "$INSTALL_DIR/scripts/mcp_command.py" "$INSTALL_DIR/scripts/plugin_command.py" "$INSTALL_DIR/scripts/notify_command.py" "$INSTALL_DIR/scripts/session_digest.py" "$INSTALL_DIR/scripts/opencode_session.sh"
6464
ln -sfn "$INSTALL_DIR/opencode.json" "$CONFIG_PATH"
6565

6666
if [ "$SKIP_SELF_CHECK" = false ]; then
6767
printf "\nRunning self-check...\n"
6868
python3 "$INSTALL_DIR/scripts/mcp_command.py" status
6969
python3 "$INSTALL_DIR/scripts/plugin_command.py" status
7070
python3 "$INSTALL_DIR/scripts/notify_command.py" status
71+
python3 "$INSTALL_DIR/scripts/session_digest.py" show || true
7172
if ! python3 "$INSTALL_DIR/scripts/plugin_command.py" doctor; then
7273
if [ "$NON_INTERACTIVE" = true ]; then
7374
printf "\nSelf-check failed in non-interactive mode.\n" >&2
@@ -90,6 +91,8 @@ printf " /plugin status\n"
9091
printf " /plugin doctor\n"
9192
printf " /notify status\n"
9293
printf " /notify profile focus\n"
94+
printf " /digest run --reason manual\n"
95+
printf " /digest show\n"
9396
printf " /setup-keys\n"
9497
printf " /plugin enable supermemory\n"
9598
printf " /plugin disable supermemory\n"

opencode.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@
129129
"notify-visual-only": {
130130
"description": "Switch notifications to visual only",
131131
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" profile visual-only`\nShow only the command output."
132+
},
133+
"digest": {
134+
"description": "Generate or show session digests (run|show)",
135+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" $ARGUMENTS`\nShow only the command output."
136+
},
137+
"digest-run": {
138+
"description": "Generate a manual session digest now",
139+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" run --reason manual`\nShow only the command output."
140+
},
141+
"digest-show": {
142+
"description": "Show latest saved session digest",
143+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" show`\nShow only the command output."
132144
}
133145
},
134146
"plugin": [

scripts/opencode_session.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if ! command -v opencode >/dev/null 2>&1; then
5+
printf "error: opencode command not found in PATH\n" >&2
6+
exit 1
7+
fi
8+
9+
DIGEST_REASON_ON_EXIT="${DIGEST_REASON_ON_EXIT:-exit}"
10+
DIGEST_OUTPUT_PATH="${MY_OPENCODE_DIGEST_PATH:-$HOME/.config/opencode/digests/last-session.json}"
11+
DIGEST_HOOK="${MY_OPENCODE_DIGEST_HOOK:-}"
12+
13+
run_digest() {
14+
if [ -n "$DIGEST_HOOK" ]; then
15+
python3 "$HOME/.config/opencode/my_opencode/scripts/session_digest.py" run --reason "$DIGEST_REASON_ON_EXIT" --path "$DIGEST_OUTPUT_PATH" --hook "$DIGEST_HOOK" >/dev/null 2>&1 || true
16+
else
17+
python3 "$HOME/.config/opencode/my_opencode/scripts/session_digest.py" run --reason "$DIGEST_REASON_ON_EXIT" --path "$DIGEST_OUTPUT_PATH" >/dev/null 2>&1 || true
18+
fi
19+
}
20+
21+
trap run_digest EXIT
22+
23+
opencode "$@"

scripts/selftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
PLUGIN_SCRIPT = REPO_ROOT / "scripts" / "plugin_command.py"
1414
MCP_SCRIPT = REPO_ROOT / "scripts" / "mcp_command.py"
1515
NOTIFY_SCRIPT = REPO_ROOT / "scripts" / "notify_command.py"
16+
DIGEST_SCRIPT = REPO_ROOT / "scripts" / "session_digest.py"
1617
BASE_CONFIG = REPO_ROOT / "opencode.json"
1718

1819

@@ -179,6 +180,34 @@ def run_notify(*args: str) -> subprocess.CompletedProcess[str]:
179180
expect(result.returncode == 0, f"notify status failed: {result.stderr}")
180181
expect("config:" in result.stdout, "notify status should print config path")
181182

183+
digest_path = home / ".config" / "opencode" / "digests" / "selftest.json"
184+
digest_env = os.environ.copy()
185+
digest_env["MY_OPENCODE_DIGEST_PATH"] = str(digest_path)
186+
187+
result = subprocess.run(
188+
[sys.executable, str(DIGEST_SCRIPT), "run", "--reason", "selftest"],
189+
capture_output=True,
190+
text=True,
191+
env=digest_env,
192+
check=False,
193+
cwd=REPO_ROOT,
194+
)
195+
expect(result.returncode == 0, f"digest run failed: {result.stderr}")
196+
expect(digest_path.exists(), "digest run should create digest file")
197+
digest = load_json_file(digest_path)
198+
expect(digest.get("reason") == "selftest", "digest reason should match")
199+
200+
result = subprocess.run(
201+
[sys.executable, str(DIGEST_SCRIPT), "show", "--path", str(digest_path)],
202+
capture_output=True,
203+
text=True,
204+
env=digest_env,
205+
check=False,
206+
cwd=REPO_ROOT,
207+
)
208+
expect(result.returncode == 0, f"digest show failed: {result.stderr}")
209+
expect("reason: selftest" in result.stdout, "digest show should print reason")
210+
182211
print("selftest: PASS")
183212
return 0
184213

scripts/session_digest.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import os
5+
import subprocess
6+
import sys
7+
from datetime import datetime, timezone
8+
from pathlib import Path
9+
10+
11+
DEFAULT_DIGEST_PATH = Path(
12+
os.environ.get(
13+
"MY_OPENCODE_DIGEST_PATH", "~/.config/opencode/digests/last-session.json"
14+
)
15+
).expanduser()
16+
17+
18+
def now_iso() -> str:
19+
return datetime.now(timezone.utc).isoformat()
20+
21+
22+
def run_text(command: list[str]) -> str:
23+
try:
24+
result = subprocess.run(
25+
command,
26+
capture_output=True,
27+
text=True,
28+
check=False,
29+
)
30+
except Exception:
31+
return ""
32+
if result.returncode != 0:
33+
return ""
34+
return result.stdout.strip()
35+
36+
37+
def collect_git_snapshot(cwd: Path) -> dict:
38+
branch = run_text(["git", "-C", str(cwd), "branch", "--show-current"])
39+
status = run_text(["git", "-C", str(cwd), "status", "--short"])
40+
ahead_behind = run_text(["git", "-C", str(cwd), "status", "--short", "--branch"])
41+
42+
status_lines = [line for line in status.splitlines() if line.strip()]
43+
return {
44+
"branch": branch or None,
45+
"status_count": len(status_lines),
46+
"status_preview": status_lines[:20],
47+
"branch_header": ahead_behind.splitlines()[0] if ahead_behind else None,
48+
}
49+
50+
51+
def build_digest(reason: str, cwd: Path) -> dict:
52+
return {
53+
"timestamp": now_iso(),
54+
"reason": reason,
55+
"cwd": str(cwd),
56+
"git": collect_git_snapshot(cwd),
57+
}
58+
59+
60+
def write_digest(path: Path, digest: dict) -> None:
61+
path.parent.mkdir(parents=True, exist_ok=True)
62+
path.write_text(json.dumps(digest, indent=2) + "\n", encoding="utf-8")
63+
64+
65+
def run_hook(command: str, digest_path: Path) -> int:
66+
env = os.environ.copy()
67+
env["MY_OPENCODE_DIGEST_PATH"] = str(digest_path)
68+
result = subprocess.run(command, shell=True, env=env, check=False)
69+
return result.returncode
70+
71+
72+
def print_summary(path: Path, digest: dict) -> None:
73+
print(f"digest: {path}")
74+
print(f"timestamp: {digest.get('timestamp')}")
75+
print(f"reason: {digest.get('reason')}")
76+
print(f"cwd: {digest.get('cwd')}")
77+
git = digest.get("git", {}) if isinstance(digest.get("git"), dict) else {}
78+
print(f"branch: {git.get('branch')}")
79+
print(f"changes: {git.get('status_count')}")
80+
81+
82+
def usage() -> int:
83+
print(
84+
'usage: /digest run [--reason <idle|exit|manual>] [--path <digest.json>] [--hook "command"] | /digest show [--path <digest.json>]'
85+
)
86+
return 2
87+
88+
89+
def parse_option(argv: list[str], name: str) -> str | None:
90+
if name not in argv:
91+
return None
92+
index = argv.index(name)
93+
if index + 1 >= len(argv):
94+
return None
95+
return argv[index + 1]
96+
97+
98+
def command_run(argv: list[str]) -> int:
99+
reason = parse_option(argv, "--reason") or "manual"
100+
path_value = parse_option(argv, "--path")
101+
hook_value = parse_option(argv, "--hook")
102+
103+
path = Path(path_value).expanduser() if path_value else DEFAULT_DIGEST_PATH
104+
cwd = Path.cwd()
105+
106+
digest = build_digest(reason=reason, cwd=cwd)
107+
write_digest(path, digest)
108+
print_summary(path, digest)
109+
110+
if hook_value:
111+
code = run_hook(hook_value, path)
112+
print(f"hook: exited with code {code}")
113+
return code
114+
115+
return 0
116+
117+
118+
def command_show(argv: list[str]) -> int:
119+
path_value = parse_option(argv, "--path")
120+
path = Path(path_value).expanduser() if path_value else DEFAULT_DIGEST_PATH
121+
if not path.exists():
122+
print(f"error: digest file not found: {path}")
123+
return 1
124+
125+
digest = json.loads(path.read_text(encoding="utf-8"))
126+
print_summary(path, digest)
127+
128+
preview = digest.get("git", {}).get("status_preview", [])
129+
if preview:
130+
print("status preview:")
131+
for line in preview:
132+
print(f"- {line}")
133+
return 0
134+
135+
136+
def main(argv: list[str]) -> int:
137+
if not argv:
138+
return usage()
139+
140+
command = argv[0]
141+
rest = argv[1:]
142+
143+
if command == "help":
144+
return usage()
145+
if command == "run":
146+
return command_run(rest)
147+
if command == "show":
148+
return command_show(rest)
149+
return usage()
150+
151+
152+
if __name__ == "__main__":
153+
try:
154+
raise SystemExit(main(sys.argv[1:]))
155+
except Exception as exc:
156+
print(f"error: {exc}")
157+
raise SystemExit(1)

0 commit comments

Comments
 (0)