Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ install-test: ## Run installer smoke test in temp HOME
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/mcp_command.py" status; \
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" profile lean; \
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" doctor --json; \
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/notify_command.py" status
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/notify_command.py" status; \
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/session_digest.py" run --reason install-test; \
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/session_digest.py" show

release: ## Create and publish release (VERSION=0.1.1)
@test -n "$(VERSION)" || (echo "VERSION is required, eg: make release VERSION=0.1.1" && exit 2)
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This repo gives you a clean, portable OpenCode setup with fast MCP controls insi
- 🧠 Built-in `/mcp` command for `status`, `enable`, and `disable`.
- 🎛️ Built-in `/plugin` command to enable or disable plugins without editing JSON.
- 🔔 Built-in `/notify` command to tune notification behavior by level (all, channel, event, per-channel event).
- 🧾 Built-in `/digest` command for session snapshots and optional exit hooks.
- 💸 Better token control by enabling `context7` / `gh_grep` only on demand.
- 🔒 Autonomous-friendly permissions for trusted project paths.
- 🔁 Easy updates by rerunning the installer.
Expand Down Expand Up @@ -195,12 +196,43 @@ Autocomplete-friendly shortcuts:
- event: `events.<type>`
- per-event channel: `channels.<type>.sound|visual`

## Session digest inside OpenCode 🧾

Use these directly in OpenCode:

```text
/digest run --reason manual
/digest show
```

Autocomplete-friendly shortcuts:

```text
/digest-run
/digest-show
```

The digest command writes to `~/.config/opencode/digests/last-session.json` by default.

For automatic digest-on-exit behavior (including `Ctrl+C`), launch OpenCode through:

```bash
~/.config/opencode/my_opencode/scripts/opencode_session.sh
```

Optional environment variables:
- `MY_OPENCODE_DIGEST_PATH` custom output path
- `MY_OPENCODE_DIGEST_HOOK` command to run after digest is written
- `DIGEST_REASON_ON_EXIT` custom reason label (default `exit`)

## Repo layout 📦

- `opencode.json` - global OpenCode config (linked to default path)
- `scripts/mcp_command.py` - backend script for `/mcp`
- `scripts/plugin_command.py` - backend script for `/plugin`
- `scripts/notify_command.py` - backend script for `/notify`
- `scripts/session_digest.py` - backend script for `/digest`
- `scripts/opencode_session.sh` - optional wrapper to run digest on process exit
- `install.sh` - one-step installer/updater
- `Makefile` - common maintenance commands (`make help`)
- `.github/workflows/ci.yml` - CI checks and installer smoke test
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,15 @@ if [ -n "$REPO_REF" ]; then
git -C "$INSTALL_DIR" checkout "$REPO_REF"
fi

chmod +x "$INSTALL_DIR/scripts/mcp_command.py" "$INSTALL_DIR/scripts/plugin_command.py" "$INSTALL_DIR/scripts/notify_command.py"
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"
ln -sfn "$INSTALL_DIR/opencode.json" "$CONFIG_PATH"

if [ "$SKIP_SELF_CHECK" = false ]; then
printf "\nRunning self-check...\n"
python3 "$INSTALL_DIR/scripts/mcp_command.py" status
python3 "$INSTALL_DIR/scripts/plugin_command.py" status
python3 "$INSTALL_DIR/scripts/notify_command.py" status
python3 "$INSTALL_DIR/scripts/session_digest.py" show || true
if ! python3 "$INSTALL_DIR/scripts/plugin_command.py" doctor; then
if [ "$NON_INTERACTIVE" = true ]; then
printf "\nSelf-check failed in non-interactive mode.\n" >&2
Expand All @@ -90,6 +91,8 @@ printf " /plugin status\n"
printf " /plugin doctor\n"
printf " /notify status\n"
printf " /notify profile focus\n"
printf " /digest run --reason manual\n"
printf " /digest show\n"
printf " /setup-keys\n"
printf " /plugin enable supermemory\n"
printf " /plugin disable supermemory\n"
12 changes: 12 additions & 0 deletions opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@
"notify-visual-only": {
"description": "Switch notifications to visual only",
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" profile visual-only`\nShow only the command output."
},
"digest": {
"description": "Generate or show session digests (run|show)",
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" $ARGUMENTS`\nShow only the command output."
},
"digest-run": {
"description": "Generate a manual session digest now",
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" run --reason manual`\nShow only the command output."
},
"digest-show": {
"description": "Show latest saved session digest",
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" show`\nShow only the command output."
}
},
"plugin": [
Expand Down
23 changes: 23 additions & 0 deletions scripts/opencode_session.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail

if ! command -v opencode >/dev/null 2>&1; then
printf "error: opencode command not found in PATH\n" >&2
exit 1
fi

DIGEST_REASON_ON_EXIT="${DIGEST_REASON_ON_EXIT:-exit}"
DIGEST_OUTPUT_PATH="${MY_OPENCODE_DIGEST_PATH:-$HOME/.config/opencode/digests/last-session.json}"
DIGEST_HOOK="${MY_OPENCODE_DIGEST_HOOK:-}"

run_digest() {
if [ -n "$DIGEST_HOOK" ]; then
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
else
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
fi
}

trap run_digest EXIT

opencode "$@"
29 changes: 29 additions & 0 deletions scripts/selftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
PLUGIN_SCRIPT = REPO_ROOT / "scripts" / "plugin_command.py"
MCP_SCRIPT = REPO_ROOT / "scripts" / "mcp_command.py"
NOTIFY_SCRIPT = REPO_ROOT / "scripts" / "notify_command.py"
DIGEST_SCRIPT = REPO_ROOT / "scripts" / "session_digest.py"
BASE_CONFIG = REPO_ROOT / "opencode.json"


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

digest_path = home / ".config" / "opencode" / "digests" / "selftest.json"
digest_env = os.environ.copy()
digest_env["MY_OPENCODE_DIGEST_PATH"] = str(digest_path)

result = subprocess.run(
[sys.executable, str(DIGEST_SCRIPT), "run", "--reason", "selftest"],
capture_output=True,
text=True,
env=digest_env,
check=False,
cwd=REPO_ROOT,
)
expect(result.returncode == 0, f"digest run failed: {result.stderr}")
expect(digest_path.exists(), "digest run should create digest file")
digest = load_json_file(digest_path)
expect(digest.get("reason") == "selftest", "digest reason should match")

result = subprocess.run(
[sys.executable, str(DIGEST_SCRIPT), "show", "--path", str(digest_path)],
capture_output=True,
text=True,
env=digest_env,
check=False,
cwd=REPO_ROOT,
)
expect(result.returncode == 0, f"digest show failed: {result.stderr}")
expect("reason: selftest" in result.stdout, "digest show should print reason")

print("selftest: PASS")
return 0

Expand Down
157 changes: 157 additions & 0 deletions scripts/session_digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python3

import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


DEFAULT_DIGEST_PATH = Path(
os.environ.get(
"MY_OPENCODE_DIGEST_PATH", "~/.config/opencode/digests/last-session.json"
)
).expanduser()


def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()


def run_text(command: list[str]) -> str:
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
except Exception:
return ""
if result.returncode != 0:
return ""
return result.stdout.strip()


def collect_git_snapshot(cwd: Path) -> dict:
branch = run_text(["git", "-C", str(cwd), "branch", "--show-current"])
status = run_text(["git", "-C", str(cwd), "status", "--short"])
ahead_behind = run_text(["git", "-C", str(cwd), "status", "--short", "--branch"])

status_lines = [line for line in status.splitlines() if line.strip()]
return {
"branch": branch or None,
"status_count": len(status_lines),
"status_preview": status_lines[:20],
"branch_header": ahead_behind.splitlines()[0] if ahead_behind else None,
}


def build_digest(reason: str, cwd: Path) -> dict:
return {
"timestamp": now_iso(),
"reason": reason,
"cwd": str(cwd),
"git": collect_git_snapshot(cwd),
}


def write_digest(path: Path, digest: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(digest, indent=2) + "\n", encoding="utf-8")


def run_hook(command: str, digest_path: Path) -> int:
env = os.environ.copy()
env["MY_OPENCODE_DIGEST_PATH"] = str(digest_path)
result = subprocess.run(command, shell=True, env=env, check=False)
return result.returncode


def print_summary(path: Path, digest: dict) -> None:
print(f"digest: {path}")
print(f"timestamp: {digest.get('timestamp')}")
print(f"reason: {digest.get('reason')}")
print(f"cwd: {digest.get('cwd')}")
git = digest.get("git", {}) if isinstance(digest.get("git"), dict) else {}
print(f"branch: {git.get('branch')}")
print(f"changes: {git.get('status_count')}")


def usage() -> int:
print(
'usage: /digest run [--reason <idle|exit|manual>] [--path <digest.json>] [--hook "command"] | /digest show [--path <digest.json>]'
)
return 2


def parse_option(argv: list[str], name: str) -> str | None:
if name not in argv:
return None
index = argv.index(name)
if index + 1 >= len(argv):
return None
return argv[index + 1]


def command_run(argv: list[str]) -> int:
reason = parse_option(argv, "--reason") or "manual"
path_value = parse_option(argv, "--path")
hook_value = parse_option(argv, "--hook")

path = Path(path_value).expanduser() if path_value else DEFAULT_DIGEST_PATH
cwd = Path.cwd()

digest = build_digest(reason=reason, cwd=cwd)
write_digest(path, digest)
print_summary(path, digest)

if hook_value:
code = run_hook(hook_value, path)
print(f"hook: exited with code {code}")
return code

return 0


def command_show(argv: list[str]) -> int:
path_value = parse_option(argv, "--path")
path = Path(path_value).expanduser() if path_value else DEFAULT_DIGEST_PATH
if not path.exists():
print(f"error: digest file not found: {path}")
return 1

digest = json.loads(path.read_text(encoding="utf-8"))
print_summary(path, digest)

preview = digest.get("git", {}).get("status_preview", [])
if preview:
print("status preview:")
for line in preview:
print(f"- {line}")
return 0


def main(argv: list[str]) -> int:
if not argv:
return usage()

command = argv[0]
rest = argv[1:]

if command == "help":
return usage()
if command == "run":
return command_run(rest)
if command == "show":
return command_show(rest)
return usage()


if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv[1:]))
except Exception as exc:
print(f"error: {exc}")
raise SystemExit(1)