@letta-ai/openhands-dreaming
v0.1.5
Published
OpenHands automation CLI that distills coding-session transcripts into a repo's AGENTS.md via Letta reflection
Keywords
Readme
openhands-dreaming
@letta-ai/openhands-dreaming — an OpenHands automation CLI that runs background
Letta dreaming over your local Agent
Canvas coding sessions: it distills them into durable memory, projects what it
learns into the target repo's AGENTS.md, and opens/updates a pull request.
bun add --global @letta-ai/openhands-dreamingMVP scope
Local Agent Canvas only ("poll locally on your laptop" automation mode):
- reads conversations from
~/.openhands/agent-canvas/dev_conversations/<conversation-id>/; - uses one persistent local Letta dreamer agent per target repo (found/created
by tags
openhands-dreaming+repo:<owner>/<repo>; memory accumulates in its memfs across runs); - passes conversation directories directly to
letta dream; - opens/updates a GitHub PR when
AGENTS.mdchanges.
OpenHands Cloud sources (--from openhands:<cloud-conversation-id>) and a
native dream() SDK integration (letta-agent-sdk) are deliberate follow-ups.
Commands
openhands-dreaming run [flags] # the sweep (cron entrypoint)
openhands-dreaming doctor # read-only preflight of the environment
openhands-dreaming cursor get|set|reset # inspect / mutate the sweep cursor
openhands-dreaming --versionrun flags: --repo <owner/repo>, --model <handle>, --to <path> (default
AGENTS.md), --base-branch <name> (default: auto-detected from the repo),
--conversations-dir <path>, --lookback <minutes> (first-run bound, default
1440), --max-conversations <n> (default 3), --dream-timeout <seconds> (per-conversation
letta dream timeout, default 900), --conversation-id <id> (pinned mode: sweep
exactly that conversation every run, bypassing the cursor window and exclusion
rules — letta's dedupe makes re-sweeps free; a missing conversation fails the
run loudly), --dry-run (read-only plan), --json.
doctor checks: repo configured + reachable + push permission, model + its
provider API key present, conversations dir exists, letta binary resolves and
runs, state store reachable. Every failure mode you would hit in a cron run
surfaces there instead.
How a sweep works
- Loads config (flags >
DREAMING_*env > defaults) and the cursor state from OpenHands Automation KV (AUTOMATION_API_URL/AUTOMATION_KV_TOKEN), or a durable local fallback file when KV is not configured. - Acquires a lock lease so two runs never dream against the same agent. The
lock is REPO-scoped (KV key
dreaming-lock:<owner>__<repo>), shared across all automations targeting the repo — pinned and sweep automations mutually exclude even though each keeps its own cursor record. - Selects conversations by the global exclusive cursor:
events/-dir mtime> processed_throughand<= now - 5s(first run bounded by--lookback). - Finds or creates the repo's dreamer agent (
dreamer:<repo>; the model is set at agent creation —letta dreamtakes no model flag). - Shallow-clones the target repo; reuses the open dream PR's branch if one
exists, else the remote
dream/local/<owner>__<repo>branch, else cuts it fresh from the base branch. - Runs
letta dream --memory <agent-id> --from openhands:<conversation-dir> --to <checkout>/AGENTS.md --timeout 900 --jsonper conversation. Ano_payloadresult (nothing new since the last reflection) is a skip, not a failure. - If
AGENTS.mdchanged: commits as the token owner with aCo-authored-by: Letta Code <[email protected]>trailer, pushes the dream branch (lease-protected force push), and opens or updates the PR. The deterministic title/body summarize the doc diff (changed headings, or the added guidance lines themselves) and name the source conversation; the body ends with👾 Generated with [Letta Code](https://www.letta.com). When the doc changed AND an OpenHands agent server is reachable (AGENT_SERVER_URL+ session key, present in the automation runtime), the CLI additionally dispatches a PR-writer conversation: an OpenHands agent that reads the PR diff and rewrites the title/body as real prose, PATCHing the PR itself viaGITHUB_TOKEN. Fire-and-forget and best-effort - the deterministic text remains the fallback. Disable withDREAMING_PR_WRITER=off. Never dispatched for noop runs. - Fires the automation completion callback and advances
processed_throughonly after the entire run succeeds — failed runs are safely retried (Letta dedupes re-fed transcripts by source message id).
Configuration
Non-secret config is passed as flags (baked into the automation entrypoint by
the OpenHands dreaming extension). Secrets are read from the environment
first; when missing (the local automation runtime does not export named
secrets into the subprocess), the CLI fetches them from the Agent Server
secret store (GET $AGENT_SERVER_URL/api/settings/secrets/<name> with
X-Session-API-Key) and injects them for itself and the letta subprocess.
| Variable | Meaning |
|---|---|
| GITHUB_TOKEN | required; repo write access (clone, push, PR) |
| provider API key | required; matches the model, e.g. OPENAI_API_KEY for openai/* |
| DREAMING_TARGET_REPO / DREAMING_MODEL | env alternatives to --repo / --model |
| DREAMING_TO / DREAMING_TARGET_BASE_BRANCH | env alternatives to --to / --base-branch |
| DREAMING_CONVERSATIONS_DIR | default ~/.openhands/agent-canvas/dev_conversations |
| DREAMING_CONVERSATION_ID | pinned mode: sweep exactly this conversation id |
| DREAMING_LOOKBACK_MINUTES / DREAMING_MAX_CONVERSATIONS | selection bounds |
| DREAMING_TIMEOUT | per-conversation letta dream --timeout seconds (default 900) |
| DREAMING_PR_WRITER | off disables the PR-writer conversation dispatch |
| DREAMING_STATE_FILE | local fallback state path (default ~/.cache/openhands-dreaming/<owner>__<repo>.json) |
| DREAMING_LOCK_TTL_SECONDS | lock lease TTL (default 3600) |
| AUTOMATION_API_URL / AUTOMATION_KV_TOKEN | OpenHands KV state store (key dreaming-cursor:<automation-id>:<owner>__<repo>) |
| AUTOMATION_CALLBACK_URL / AUTOMATION_CALLBACK_API_KEY / AUTOMATION_RUN_ID | completion callback |
| LETTA_BIN | override the letta binary (defaults to the pinned @letta-ai/letta-code dependency) |
Using it from OpenHands
The dreaming extension (skill + automations-catalog entry in
OpenHands/extensions) is the intended consumer: /dreaming:setup collects
config, bakes it into a two-file tarball (setup.sh installs bun + this CLI;
run.sh execs openhands-dreaming run … --json), and registers a cron
automation.
Try it without OpenHands
fixtures/conversations/ contains a small fake Agent Canvas conversation
(conv-demo) so you can exercise the full pipeline with nothing but this
repo, a model API key, and (for the PR step) a GitHub token.
Point a sweep at the fixtures and check the plan (read-only):
OPENAI_API_KEY=... \ bun src/cli.ts run --repo <you>/<scratch-repo> --model openai/gpt-5.5 \ --conversations-dir ./fixtures/conversations --dry-runYou should see
conv-demoinwouldProcess.Run it for real against a scratch repo you can push to (
GITHUB_TOKENneeds write access). Dreaming reflects the fixture conversation and opens a PR whoseAGENTS.mdcaptures the conventions planted in the transcript (bun over npm,bun run checkbefore PRs, co-located*.test.ts):GITHUB_TOKEN=... OPENAI_API_KEY=... \ bun src/cli.ts run --repo <you>/<scratch-repo> --model openai/gpt-5.5 \ --conversations-dir ./fixtures/conversations --jsonTo test a change you expect to see in
AGENTS.md: add a new event file tofixtures/conversations/conv-demo/events/with the next sequence number and a NEW id — e.g. copyevent-00008-*.json, bump the filename toevent-00009-<new-uuid>.json, set"id"to the same new uuid, a later"timestamp", and put your convention in the message text. Then re-run step 2 and confirm the PR updates with your addition.Two mechanics to know:
- letta dedupes by event id — editing an existing event's text in place is treated as already-reflected and ignored; new content needs a new id;
- creating the file bumps the events-dir mtime, which is what makes the
next sweep re-select the conversation. If a sweep already advanced past
your edit, rewind with
bun src/cli.ts cursor reset --repo <you>/<scratch-repo>.
Importing Claude Code sessions
scripts/import-claude.ts converts a Claude Code session transcript into an
OpenHands-format conversation directory so dreaming can sweep it too:
bun scripts/import-claude.ts \
--from ~/.claude/projects/<project>/<session-id>.jsonl \
--out ~/.openhands/claude-transcriptsRe-run it any time the session grows — it is incremental (a
.claude-import.json sidecar tracks the last converted message) and
idempotent (event ids are the Claude message uuids, so letta's
source_message_id dedupe absorbs any overlap). Known secret patterns
(GitHub/OpenAI/Anthropic/Slack tokens, bearer headers) are redacted before
anything is written to disk.
To sweep imports on a schedule, register a second dreaming automation whose
run.sh bakes --conversations-dir ~/.openhands/claude-transcripts — the
KV cursor key is namespaced per automation id, so it cannot collide with the
Agent Canvas automation. For a one-off:
GITHUB_TOKEN=... OPENAI_API_KEY=... \
bun src/cli.ts run --repo <owner>/<repo> --model <handle> \
--conversations-dir ~/.openhands/claude-transcripts --jsonHandoff: swapping to the letta-agent-sdk dream()
The planned next step is replacing the letta dream subprocess with the
TypeScript dream() from letta-agent-sdk, which also enables
multi-conversation reflection (one call over N sources with internal
parallelism, instead of this repo's sequential one-conversation-per-call
loop). Notes for that change:
The seam is src/letta.ts — nothing else talks to letta. Discovery,
state/cursor, git/PR, and the orchestrator are transport-agnostic. The swap
should replace findOrCreateDreamerAgent/runDream internals and leave
their call sites in src/run.ts mostly intact (the per-conversation loop
collapses into one dream({ sources: [...] }) call).
Invariants that must survive (the regression contract):
- One real Letta agent per target repo, found by tags
(
openhands-dreaming+repo:<owner>/<repo>), model set at creation. The SDK takes amemoryDirinstead of an agent id — pass the agent's memfs (~/.letta/agents/<agent-id>/memory); do NOT switch to a standalone memory dir (later work assumes a real letta memfs). - Doc projection (
--to): today the subprocess owns seedingAGENTS.mdinto memfs (frontmatter added) and exporting it back out (frontmatter stripped).dream()has no target option — this glue must be re-implemented around the SDK call or added to the SDK itself. - Letta-side dedupe is the correctness boundary. The sweep cursor here
is only a performance filter; re-feeding an already-reflected
conversation must stay cheap and safe (today:
no_payload→ skip, never throw). Convergence across cron ticks (a killed run's completed work is skipped quickly next run) depends on letta advancing its own per-conversation cursor — verify the SDK preserves per-source cursoring. - Cursor advance stays all-or-nothing (
processed_throughmoves only after full success). The capped-run prefix rule insrc/run.tscursorAdvanceTargetassumes oldest-first prefix processing; with one batcheddream()call decide explicitly whether a partial batch can succeed, and keep the never-skip property (step back on mtime ties; re-feed rather than skip). - Lock lease refresh: today it refreshes between conversations. One
long
dream()call needs a different cadence (timer or SDK progress callback) so the lease outlives the reflection. - PR conventions: commit author = token owner, Letta Code co-author
trailer,
docs:titles derived from the doc diff, body diffed against the base branch,👾footer.
Validation recipe (no OpenHands needed): bun run check (unit suite
encodes the argv/protocol contracts you are replacing — update
src/letta.test.ts deliberately, not incidentally), then the fixture flow
above with BOTH fixture conversations: fixtures/conversations contains
conv-demo and conv-demo-2 with disjoint conventions precisely so
multi-conversation reflection can be proven — one sweep should produce one
PR whose AGENTS.md captures conventions from both (bun/bun run check
from the first; conventional commits/PR-only-workflow from the second).
Re-run the sweep afterwards and confirm a NOOP (dedupe + cursor intact).
Development
bun install
bun run check # typecheck + unit tests
bun src/cli.ts doctor --repo <owner/repo> --model <handle>Releases are tag-driven: bump version in package.json, tag v<version>,
push the tag — .github/workflows/publish.yml runs the checks and publishes
to npm (requires the NPM_TOKEN repo secret).
main.py, setup.sh, and deploy.sh are the retired Python prototype, kept
as a behavioral reference until the extension-side E2E completes.
