Jules Turndown

Jules

5 min read Original article ↗

I want to export all my data from Google Jules into a local folder: settings, environment

variables, scheduled tasks, connected repos, memories, setup scripts, and every session (prompt,

plan, full activity log, and code patches). Please do this in order:

1. Setup

- Check whether the Jules CLI is installed (`which jules`). If it isn't, install it with

`npm install -g @google/jules`. If npm isn't installed, tell me and stop.

- Check whether I'm logged in: `security find-generic-password -s jules-cli -a default`

on macOS (metadata only, without -w), or `secret-tool lookup service jules-cli username default`

on Linux. If there's no entry, ask me to run `! jules login` myself. It's an interactive

browser sign-in, so don't try to run it for me. Wait until I say it's done.

- Run `jules remote list --repo`. If it fails with a 401 and asks about GitHub, tell me to

connect my GitHub account at

https://github.com/apps/google-labs-jules/installations/select_target and wait for me to

confirm. If I've never used Jules with GitHub repos, I can skip this; the export will just

have no repo data.

2. Export: save the script below to a file and run it from my home directory. Then tell me what

was exported (the counts it prints) and the full path of the folder. Don't print my access

token or the values in env-variables.json.

Problems someone else already ran into:

- The Jules CLI stores its login in the macOS Keychain (service "jules-cli", account "default")

as "go-keyring-base64:<base64 JSON>". The script decodes that. The access token expires about

an hour after login; if you get 401s on every call, run any `jules` command or ask me to run

`! jules login` again.

- If ListSources returns 401 "missing required authentication credential" but the other calls

work, my GitHub account isn't connected to Jules (see step 1).

- Settings, env vars, repos and memories come from an internal `$rpc` endpoint. Sessions come

from REST endpoints under https://aida.googleapis.com/v1/swebot (/tasks, /tasks/{id},

/tasks/{id}/activities), which are what the CLI itself uses. Both are undocumented, so if a

call starts returning 404/400, tell me which one rather than guessing at fixes.

- The repo list doesn't include setup scripts or test commands; the script recovers them from

each session's code output (repoMetadata). Empty memories responses ({}) just mean there are

no memories.

- The API may return a few more sessions than `jules remote list --session` shows (older ones).

That's expected.

```python

import base64, json, pathlib, re, subprocess, sys, urllib.error, urllib.parse, urllib.request

def get_token():

for cmd in [["security", "find-generic-password", "-s", "jules-cli", "-a", "default", "-w"],

["secret-tool", "lookup", "service", "jules-cli", "username", "default"]]:

try:

out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode().strip()

if out.startswith("go-keyring-base64:"):

out = base64.b64decode(out.split(":", 1)[1]).decode()

if out: return json.loads(out)["access_token"]

except Exception: pass

p = pathlib.Path.home() / ".jules/cache/oauth_creds.json"

if p.exists(): return json.loads(p.read_text())["access_token"]

sys.exit("Run `jules login` first.")

TOKEN = get_token()

HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

RPC = "https://aida.googleapis.com/$rpc/language_labs.aida.swebot.proto.SweBotService"

REST = "https://aida.googleapis.com/v1/swebot"

OUT = pathlib.Path("./jules-export"); OUT.mkdir(exist_ok=True)

errors = []

def rpc(m, b=None):

req = urllib.request.Request(f"{RPC}/{m}", data=json.dumps(b or {}).encode(), headers=HEADERS, method="POST")

with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read().decode() or "{}")

def get(path, **params):

q = ("?" + urllib.parse.urlencode(params)) if params else ""

with urllib.request.urlopen(urllib.request.Request(f"{REST}{path}{q}", headers=HEADERS), timeout=60) as r:

return json.loads(r.read().decode() or "{}")

def paged(fetch, key):

items, token = [], None

while True:

resp = fetch(token)

items += resp.get(key, [])

token = resp.get("nextPageToken")

if not token: return items

def safe(s): return re.sub(r"[^a-zA-Z0-9._-]", "_", s)

def dump(path, obj): path.parent.mkdir(parents=True, exist_ok=True); path.write_text(json.dumps(obj, indent=2))

# Account-level data

dump(OUT / "user-settings.json", rpc("GetUserSettings"))

dump(OUT / "env-variables.json", rpc("ListEnvironmentVariables"))

dump(OUT / "scheduled-tasks.json", rpc("ListScheduledTasks"))

# Repos (sources) and their memories

sources = paged(lambda t: rpc("ListSources", {"pageSize": 100, **({"pageToken": t} if t else {})}), "sources")

dump(OUT / "sources.json", sources)

mem_repos = 0

for src in sources:

sid = src.get("sourceId") or src.get("id") or src.get("name", "").removeprefix("sources/")

try:

mems = paged(lambda t: rpc("ListMemories", {"sourceId": sid, "pageSize": 100, **({"pageToken": t} if t else {})}), "memories")

except urllib.error.HTTPError as e:

errors.append(f"memories {sid}: HTTP {e.code}"); continue

if mems:

mem_repos += 1

d = OUT / "repos" / safe(sid)

dump(d / "memories.json", mems)

(d / "memories.md").write_text("# Memories\n\n" + "\n".join(

f"- {m.get('Fact') or m.get('fact') or m.get('content')}" for m in mems if not m.get("archived")) + "\n")

# Sessions: full detail, activity log, patches, and a readable summary

sessions = paged(lambda t: get("/tasks", pageSize=100, **({"pageToken": t} if t else {})), "tasks")

dump(OUT / "sessions.json", sessions)

setup = {} # latest setup script / test command seen per repo

patches = 0

for s in sorted(sessions, key=lambda s: s.get("createdAt", "")):

tid = s["id"]; d = OUT / "sessions" / tid

try:

task = get(f"/tasks/{tid}").get("task", {})

acts = paged(lambda t: get(f"/tasks/{tid}/activities", pageSize=100, **({"pageToken": t} if t else {})), "activitySteps")

except urllib.error.HTTPError as e:

errors.append(f"session {tid}: HTTP {e.code}"); continue

dump(d / "session.json", task)

dump(d / "activities.json", acts)

commits = [o["gitCommit"] for o in task.get("outputs", []) if "gitCommit" in o]

for i, c in enumerate(commits, 1):

if c.get("patch", {}).get("unidiff"):

(d / f"patch-{i}.diff").write_text(c["patch"]["unidiff"]); patches += 1

meta = c.get("repoMetadata", {})

if meta.get("environmentSetupScript") or meta.get("testCommand"):

setup[task.get("sourceId") or meta.get("name") or tid] = meta

plan = task.get("latestPlan", {}).get("steps", [])

kinds = {}

for a in acts:

for actor, v in a.items():

if isinstance(v, dict):

for k in v: kinds[f"{actor}.{k}"] = kinds.get(f"{actor}.{k}", 0) + 1

lines = [f"# {task.get('title') or s.get('suggestedTitle') or tid}", "",

f"- **ID:** {tid}", f"- **Repo:** {task.get('sourceId', '')}",

f"- **Status:** {task.get('taskStatus', '')}", f"- **Created:** {task.get('createdAt', '')}",

f"- **Completed:** {task.get('completedAt', '')}", "", "## Prompt", "", task.get("description", ""), ""]

if plan: lines += ["## Latest plan", ""] + [f"{p.get('index', 0) + 1}. {p.get('title', '')}" for p in plan] + [""]

if commits: lines += ["## Code outputs", ""] + [

f"- `patch-{i}.diff`: {c.get('title') or (c.get('commitMessage') or '').split(chr(10))[0]} (branch `{c.get('gitBranchName', '')}`)"

for i, c in enumerate(commits, 1)] + [""]

if pub := task.get("latestPublish", {}).get("commitInfo"):

lines += ["## Published", "", f"- Commit `{pub.get('commitId', '')}` on `{pub.get('branchName', '')}`", ""]

lines += ["## Activity", "", f"{len(acts)} steps (full log in `activities.json`):", ""] + [f"- {k}: {n}" for k, n in sorted(kinds.items())]

(d / "summary.md").write_text("\n".join(lines) + "\n")

for sid, meta in setup.items():

d = OUT / "repos" / safe(sid); d.mkdir(parents=True, exist_ok=True)

if meta.get("environmentSetupScript"): (d / "setup.sh").write_text(meta["environmentSetupScript"] + "\n")

if meta.get("testCommand"): (d / "test-command.txt").write_text(meta["testCommand"] + "\n")

print(json.dumps({"repos": len(sources), "repos_with_memories": mem_repos, "sessions": len(sessions),

"patches": patches, "repos_with_setup_or_test": len(setup),

"error_count": len(errors), "errors": errors[:10]}, indent=2))

print(f"Exported all Jules data to {OUT.resolve()}")

```