Compare commits
2 Commits
main
...
demo/openc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef048d044c | ||
|
|
7dac9e39f1 |
27
demos/README.md
Normal file
27
demos/README.md
Normal 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
109
demos/openclaw-release-tweet.sh
Executable 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
|
||||
@ -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,
|
||||
};
|
||||
|
||||
118
src/workflows/openclaw_release.ts
Normal file
118
src/workflows/openclaw_release.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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.',
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user