Compare commits

...

2 Commits

Author SHA1 Message Date
vignesh07
ef048d044c feat(workflows): add openclaw.release tweet + post workflows 2026-02-04 15:04:14 -08:00
vignesh07
7dac9e39f1 demo: add OpenClaw release tweet generator 2026-02-04 14:56:45 -08:00
6 changed files with 309 additions and 1 deletions

27
demos/README.md Normal file
View File

@ -0,0 +1,27 @@
# Lobster demos
## OpenClaw release tweet
A stage-friendly demo: read OpenClaw commits + changelog and generate a release tweet in one of three styles, with an approval gate and optional posting.
File: `demos/openclaw-release-tweet.sh`
### Prereqs
- OpenClaw repo checked out locally (default path: `../openclaw`)
- `jq`
- A running Clawdbot/Moltbot Gateway for `llm_task.invoke`:
- `CLAWD_URL` e.g. `http://127.0.0.1:19700/tools/invoke`
- `CLAWD_TOKEN` bearer token
- Optional: `bird` CLI installed + authenticated (only if `POST=true`)
### Run
```bash
cd lobster
STYLE=sassy POST=false ./demos/openclaw-release-tweet.sh
STYLE=professional POST=false ./demos/openclaw-release-tweet.sh
STYLE=drybread POST=true ./demos/openclaw-release-tweet.sh
```
### Notes
- By default it uses commits from `HEAD~30..HEAD`. Override with `SINCE_REF=<tag-or-sha>`.
- Set `LINK=https://...` to point at your release notes / site.

109
demos/openclaw-release-tweet.sh Executable file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -euo pipefail
# OpenClaw release tweet demo (Lobster)
#
# What it does:
# - Reads commits since a ref (defaults to HEAD~30 or STATE_REF if set)
# - Reads top section of CHANGELOG.md
# - Uses llm_task.invoke to generate ONE tweet in the requested style
# - Shows an approval gate
# - Optionally posts via bird CLI
#
# Prereqs:
# - OpenClaw repo checked out locally (default: ../openclaw)
# - jq installed
# - Clawdbot/Moltbot gateway reachable for llm_task.invoke via env:
# export CLAWD_URL='http://127.0.0.1:19700/tools/invoke'
# export CLAWD_TOKEN='<token>'
# - bird CLI installed+authed if POST=true
#
# Usage:
# STYLE=sassy POST=false ./demos/openclaw-release-tweet.sh
# STYLE=professional POST=false ./demos/openclaw-release-tweet.sh
# STYLE=drybread POST=true ./demos/openclaw-release-tweet.sh
STYLE=${STYLE:-professional} # sassy|professional|drybread
POST=${POST:-false} # true|false
REPO_DIR=${REPO_DIR:-../openclaw}
SINCE_REF=${SINCE_REF:-}
MAX_COMMITS=${MAX_COMMITS:-30}
LINK=${LINK:-https://openclaw.dev}
if [[ ! -d "$REPO_DIR/.git" ]]; then
echo "Repo not found: $REPO_DIR (expected a git repo). Set REPO_DIR=..." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required" >&2
exit 1
fi
if [[ "$STYLE" != "sassy" && "$STYLE" != "professional" && "$STYLE" != "drybread" ]]; then
echo "STYLE must be one of: sassy|professional|drybread" >&2
exit 1
fi
# Determine since ref
if [[ -z "$SINCE_REF" ]]; then
SINCE_REF="HEAD~${MAX_COMMITS}"
fi
COMMITS=$(cd "$REPO_DIR" && git log --no-merges --pretty=format:'%h %s (%an)' "$SINCE_REF..HEAD" | head -n 80 | jq -R -s -c 'split("\n") | map(select(length>0))')
CHANGELOG=$(cd "$REPO_DIR" && node - <<'NODE'
const fs = require('fs');
const t = fs.readFileSync('CHANGELOG.md','utf8');
const parts = t.split(/\n## /);
if (parts.length > 1) {
const top = ('## ' + parts[1]).split(/\n## /)[0];
process.stdout.write(top.slice(0, 6000));
} else {
process.stdout.write(t.slice(0, 6000));
}
NODE
)
CONTEXT=$(jq -n --arg style "$STYLE" --arg since "$SINCE_REF" --arg link "$LINK" --argjson commits "$COMMITS" --arg changelog "$CHANGELOG" '{style:$style,since:$since,link:$link,commits:$commits,changelog:$changelog}')
PIPELINE=$(cat <<'EOF'
exec --json --shell 'printf "%s" "$CONTEXT"'
| llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"}},"required":["tweet"],"additionalProperties":false}' --prompt "You are writing a release tweet for OpenClaw.\n\nInput JSON has: style (sassy|professional|drybread), since, link, commits[], changelog.\n\nWrite ONE tweet in the requested style.\nConstraints:\n- <= 260 chars\n- Include the link exactly once\n- Do not hallucinate features\n- No hashtags unless truly helpful (max 1)\n\nReturn JSON: {\"tweet\":\"...\"}\n\nINPUT:\n{{.}}"
| approve --prompt 'Post this tweet?'
EOF
)
# Render pipeline with env substitutions via bash (CONTEXT)
export CONTEXT
OUT=$(node bin/lobster.js run --mode tool "$PIPELINE")
# If needs approval, show prompt + preview
STATUS=$(echo "$OUT" | jq -r '.status')
if [[ "$STATUS" == "needs_approval" ]]; then
PROMPT=$(echo "$OUT" | jq -r '.requiresApproval.prompt')
echo "\n--- APPROVAL ---\n$PROMPT\n" >&2
if [[ "$POST" == "true" ]]; then
# Approve and resume, then post via bird.
TOKEN=$(echo "$OUT" | jq -r '.requiresApproval.resumeToken')
DONE=$(node bin/lobster.js resume --token "$TOKEN" --approve yes)
TWEET=$(echo "$DONE" | jq -r '.output[0].tweet')
echo "\nPosting via bird...\n" >&2
bird post --text "$TWEET"
echo "\nDone.\n" >&2
else
echo "(Dry-run) Not posting. Set POST=true to actually post." >&2
fi
exit 0
fi
if [[ "$STATUS" == "ok" ]]; then
echo "$OUT" | jq -r '.output[0].tweet'
exit 0
fi
echo "$OUT" | jq '.'
exit 1

View File

@ -1,7 +1,10 @@
import { workflowRegistry } from '../../workflows/registry.js';
import { runGithubPrMonitorWorkflow, runGithubPrMonitorNotifyWorkflow } from '../../workflows/github_pr_monitor.js';
import { runOpenclawReleasePostWorkflow, runOpenclawReleaseTweetWorkflow } from '../../workflows/openclaw_release.js';
const runners = {
'openclaw.release.tweet': runOpenclawReleaseTweetWorkflow,
'openclaw.release.post': runOpenclawReleasePostWorkflow,
'github.pr.monitor': runGithubPrMonitorWorkflow,
'github.pr.monitor.notify': runGithubPrMonitorNotifyWorkflow,
};

View File

@ -0,0 +1,118 @@
import { parsePipeline } from '../parser.js';
import { runPipeline } from '../runtime.js';
function assertStyle(value: unknown): 'sassy' | 'professional' | 'drybread' {
const v = String(value ?? 'professional').trim().toLowerCase();
if (v === 'sassy' || v === 'professional' || v === 'drybread') return v;
return 'professional';
}
function jsonEscape(s: string) {
return s.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
}
function buildContextShell({ repoDir, sinceRef, maxCommits, link, style }: any) {
// Produce a single JSON object on stdout.
// Uses jq for safe JSON construction.
return [
`cd '${jsonEscape(repoDir)}'`,
`SINCE='${jsonEscape(sinceRef || `HEAD~${Number(maxCommits) || 30}`)}'`,
`COMMITS=$(git log --no-merges --pretty=format:'%h %s (%an)' "$SINCE..HEAD" | head -n 80 | jq -R -s -c 'split("\\n")|map(select(length>0))')`,
// Grab the topmost changelog section (first "## " header block)
`CHANGELOG=$(node -e "const fs=require('fs'); const t=fs.readFileSync('CHANGELOG.md','utf8'); const parts=t.split(/\\n## /); const top=(parts.length>1?('## '+parts[1]).split(/\\n## /)[0]:t); process.stdout.write(top.slice(0,6000));")`,
`jq -n --arg repo 'openclaw' --arg style '${jsonEscape(style)}' --arg since "$SINCE" --arg link '${jsonEscape(link)}' --argjson commits "$COMMITS" --arg changelog "$CHANGELOG" '{repo:$repo,style:$style,since:$since,link:$link,commits:$commits,changelog:$changelog}'`,
].join(' && ');
}
function buildTweetPrompt() {
return (
`You are writing a release tweet for OpenClaw.\n\n` +
`Input JSON has: style (sassy|professional|drybread), since, link, commits[], changelog.\n\n` +
`Write ONE tweet in the requested style.\n` +
`Constraints:\n` +
`- <= 260 characters\n` +
`- Include the link exactly once (use the provided link field)\n` +
`- Don\'t hallucinate features\n` +
`- No hashtags unless truly helpful (max 1)\n\n` +
`Return JSON: {"tweet":"...","style":"..."}.\n\n` +
`INPUT:\n{{.}}`
);
}
export async function runOpenclawReleaseTweetWorkflow({ args, ctx }: any) {
const repoDir = String(args.repo_dir ?? args.repoDir ?? '../openclaw');
const style = assertStyle(args.style);
const sinceRef = String(args.since_ref ?? args.sinceRef ?? '').trim();
const maxCommits = Number(args.max_commits ?? args.maxCommits ?? 30);
const link = String(args.link ?? 'https://openclaw.dev');
const contextShell = buildContextShell({ repoDir, sinceRef, maxCommits, link, style });
const pipelineString = [
`exec --json --shell '${jsonEscape(contextShell)}'`,
`llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"},"style":{"type":"string"}},"required":["tweet","style"],"additionalProperties":false}' --prompt '${jsonEscape(buildTweetPrompt())}'`,
// Human approval (interactive by default; emits approval_request in tool/non-tty)
`approve --preview-from-stdin --limit 1 --prompt 'Post this release tweet?'`,
// Output tweet object (for display or piping)
`pick tweet,style`,
].join(' | ');
const pipeline = parsePipeline(pipelineString);
const output = await runPipeline({
pipeline,
registry: ctx.registry,
input: [],
stdin: ctx.stdin,
stdout: ctx.stdout,
stderr: ctx.stderr,
env: ctx.env,
mode: ctx.mode,
});
// In human mode, approve passes items through. In tool mode, approve halts; cli will wrap.
return {
kind: 'openclaw.release.tweet',
style,
since: sinceRef || `HEAD~${maxCommits}`,
items: output.items,
halted: output.halted,
};
}
export async function runOpenclawReleasePostWorkflow({ args, ctx }: any) {
const repoDir = String(args.repo_dir ?? args.repoDir ?? '../openclaw');
const style = assertStyle(args.style);
const sinceRef = String(args.since_ref ?? args.sinceRef ?? '').trim();
const maxCommits = Number(args.max_commits ?? args.maxCommits ?? 30);
const link = String(args.link ?? 'https://openclaw.dev');
const contextShell = buildContextShell({ repoDir, sinceRef, maxCommits, link, style });
const pipelineString = [
`exec --json --shell '${jsonEscape(contextShell)}'`,
`llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"},"style":{"type":"string"}},"required":["tweet","style"],"additionalProperties":false}' --prompt '${jsonEscape(buildTweetPrompt())}'`,
`approve --preview-from-stdin --limit 1 --prompt 'Post this release tweet to X?'`,
// Post to X via bird; uses stdin json array from previous stage.
`exec --stdin json --shell 'T=$(cat | jq -r ".[0].tweet"); bird post --text "$T"; echo "{\\"posted\\":true}"' --json`,
].join(' | ');
const pipeline = parsePipeline(pipelineString);
const output = await runPipeline({
pipeline,
registry: ctx.registry,
input: [],
stdin: ctx.stdin,
stdout: ctx.stdout,
stderr: ctx.stderr,
env: ctx.env,
mode: ctx.mode,
});
return {
kind: 'openclaw.release.post',
style,
since: sinceRef || `HEAD~${maxCommits}`,
items: output.items,
halted: output.halted,
};
}

View File

@ -1,4 +1,50 @@
export const workflowRegistry = {
'openclaw.release.tweet': {
name: 'openclaw.release.tweet',
description:
'Generate an OpenClaw release tweet (sassy/professional/drybread) from commits + changelog, with approval.',
argsSchema: {
type: 'object',
properties: {
repo_dir: { type: 'string', description: 'Path to OpenClaw repo (default: ../openclaw)' },
since_ref: { type: 'string', description: 'Start ref (tag/sha). Default: HEAD~max_commits' },
max_commits: { type: 'number', description: 'Commit window when since_ref is empty (default: 30)' },
link: { type: 'string', description: 'Release notes link to include (default: https://openclaw.dev)' },
style: { type: 'string', description: 'sassy|professional|drybread (default: professional)' },
},
required: [],
},
examples: [
{
args: { style: 'sassy', since_ref: 'HEAD~30', link: 'https://openclaw.dev' },
description: 'Generate a sassy tweet from last 30 commits.',
},
],
sideEffects: [],
},
'openclaw.release.post': {
name: 'openclaw.release.post',
description:
'Generate an OpenClaw release tweet and (after approval) post to X via bird CLI.',
argsSchema: {
type: 'object',
properties: {
repo_dir: { type: 'string', description: 'Path to OpenClaw repo (default: ../openclaw)' },
since_ref: { type: 'string', description: 'Start ref (tag/sha). Default: HEAD~max_commits' },
max_commits: { type: 'number', description: 'Commit window when since_ref is empty (default: 30)' },
link: { type: 'string', description: 'Release notes link to include (default: https://openclaw.dev)' },
style: { type: 'string', description: 'sassy|professional|drybread (default: professional)' },
},
required: [],
},
examples: [
{
args: { style: 'professional', since_ref: 'HEAD~30', link: 'https://openclaw.dev' },
description: 'Approve and post a professional tweet.',
},
],
sideEffects: ['local_exec'],
},
'github.pr.monitor': {
name: 'github.pr.monitor',
description: 'Fetch PR state via gh, diff against last run, emit only on change.',

View File

@ -22,5 +22,10 @@ test('workflows.list returns known workflows', async () => {
for await (const it of res.output) items.push(it);
const names = items.map((x) => x.name).sort();
assert.deepEqual(names, ['github.pr.monitor', 'github.pr.monitor.notify']);
assert.deepEqual(names, [
'github.pr.monitor',
'github.pr.monitor.notify',
'openclaw.release.post',
'openclaw.release.tweet',
]);
});