Skip to content
Open
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"dev:force:memex": "node scripts/force-scheduled-service.cjs memex",
"dev:force:discovery": "node scripts/force-scheduled-service.cjs discovery",
"dev:force:scheduled": "node scripts/force-scheduled-service.cjs all",
"test:onboarding-steps": "npm run build:main && node scripts/test-onboarding-steps.cjs",
"test:tada-migrations": "npm run build:main && node scripts/test-tada-migrations.cjs",
"reset": "rm -f tada-config.json ~/.config/tada/google-token.json ~/.config/tada/outlook-token.json"
},
"dependencies": {
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,11 @@ namespaces = true

[tool.setuptools.package-data]
shared = ["model_catalog.json"]
"apps.moments" = [
"prompts/*.txt",
"prompts/**/*.txt",
"templates/**/*.html",
"templates/**/*.css",
"templates/**/*.js",
"templates/**/*.md",
]
38 changes: 38 additions & 0 deletions scripts/test-onboarding-steps.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const assert = require("node:assert/strict");

const { ONBOARDING_STEPS, pendingSteps } = require("../dist/shared/onboardingSteps.js");

function baseState(overrides = {}) {
return {
seenSteps: [],
featureFlags: { moments: true, memory: true },
googleConnected: false,
enabledConnectors: [],
hasLlmApiKey: false,
onboardingComplete: false,
servicesReady: false,
...overrides,
};
}

const ids = ONBOARDING_STEPS.map((step) => step.id);
assert.equal(ids[ids.indexOf("models_keys") + 1], "agent_setup");
assert.equal(ids[ids.indexOf("agent_setup") + 1], "getting_ready");

const returningUpdate = baseState({
seenSteps: ["welcome", "tabracadabra", "chat", "tadas", "memex"],
googleConnected: true,
enabledConnectors: ["screen", "accessibility"],
hasLlmApiKey: true,
onboardingComplete: true,
servicesReady: true,
});
assert.deepEqual(pendingSteps(returningUpdate).map((step) => step.id), ["agent_setup"]);

const returningAfterSeen = baseState({
...returningUpdate,
seenSteps: [...returningUpdate.seenSteps, "agent_setup"],
});
assert.deepEqual(pendingSteps(returningAfterSeen), []);

console.log("onboarding step tests passed");
88 changes: 88 additions & 0 deletions scripts/test-tada-migrations.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

const { removeLegacyMarkdownTadas } = require("../dist/main/features/tadaMigrations.js");

function write(file, content) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}

function exists(file) {
return fs.existsSync(file);
}

function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf-8"));
}

function run() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tada-migration-"));
const tada = path.join(root, "logs-tada");
const results = path.join(tada, "results");

write(path.join(tada, "research", "old-brief.md"), "---\ntitle: Old\n---\n");
write(path.join(results, "old-brief", "output", "index.md"), "# Old\n");
write(path.join(results, "old-brief", "output", "notes.md"), "# Notes\n");
write(path.join(results, "old-brief", "meta.json"), JSON.stringify({ title: "Old" }));

write(path.join(tada, "research", "html-brief.md"), "---\ntitle: HTML\n---\n");
write(path.join(results, "html-brief", "output", "index.html"), "<!doctype html>");
write(path.join(results, "html-brief", "output", "app.js"), "console.log('ok');\n");
write(path.join(results, "html-brief", "meta.json"), JSON.stringify({ title: "HTML" }));

write(path.join(tada, "research", "mixed-brief.md"), "---\ntitle: Mixed\n---\n");
write(path.join(results, "mixed-brief", "output", "index.html"), "<!doctype html>");
write(path.join(results, "mixed-brief", "output", "legacy.md"), "# Legacy sidecar\n");
write(path.join(results, "mixed-brief", "meta.json"), JSON.stringify({ title: "Mixed" }));

write(path.join(tada, "research", "orphan-task.md"), "---\ntitle: Orphan\n---\n");
write(path.join(results, "_moment_state.json"), JSON.stringify({
"old-brief": { pinned: true },
"html-brief": { pinned: false },
}, null, 2));
write(path.join(results, "_runs.jsonl"), [
JSON.stringify({ slug: "old-brief", status: "success", completed_at: 1 }),
JSON.stringify({ slug: "html-brief", status: "success", completed_at: 2 }),
"{malformed",
"",
].join("\n"));

const first = removeLegacyMarkdownTadas(tada);
assert.equal(first.legacyResultDirsDeleted, 1);
assert.equal(first.taskFilesDeleted, 1);
assert.equal(first.stateEntriesRemoved, 1);
assert.equal(first.runHistoryRowsRemoved, 1);
assert.deepEqual(first.slugsRemoved, ["old-brief"]);
assert.deepEqual(first.errors, []);

assert.equal(exists(path.join(results, "old-brief")), false);
assert.equal(exists(path.join(tada, "research", "old-brief.md")), false);
assert.equal(exists(path.join(results, "html-brief", "output", "index.html")), true);
assert.equal(exists(path.join(tada, "research", "html-brief.md")), true);
assert.equal(exists(path.join(results, "mixed-brief", "output", "legacy.md")), true);
assert.equal(exists(path.join(tada, "research", "mixed-brief.md")), true);
assert.equal(exists(path.join(tada, "research", "orphan-task.md")), true);

const state = readJson(path.join(results, "_moment_state.json"));
assert.equal(Object.hasOwn(state, "old-brief"), false);
assert.equal(Object.hasOwn(state, "html-brief"), true);

const runs = fs.readFileSync(path.join(results, "_runs.jsonl"), "utf-8");
assert.equal(runs.includes("old-brief"), false);
assert.equal(runs.includes("html-brief"), true);
assert.equal(runs.includes("{malformed"), true);

const second = removeLegacyMarkdownTadas(tada);
assert.equal(second.legacyResultDirsDeleted, 0);
assert.equal(second.taskFilesDeleted, 0);
assert.equal(second.stateEntriesRemoved, 0);
assert.equal(second.runHistoryRowsRemoved, 0);

fs.rmSync(root, { recursive: true, force: true });
}

run();
console.log("tada migration tests passed");
2 changes: 1 addition & 1 deletion src/agent/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
- {data_dir}/ (app data, logs, tasks)
- {tmp_dir}/ (temporary files)

You can browse the web using the browser_navigate, browser_read_text, browser_click, browser_type, and browser_screenshot tools. These may optionally use the user's Chrome cookies when they're available, you can access authenticated pages (Twitter, Gmail, etc.). Use browser_read_text with a CSS selector to narrow down content on large pages.
You can browse the web using the browser_navigate, browser_read_text, browser_click, browser_type, and browser_screenshot tools. These may optionally use the user's Chrome cookies when permission is available, which can make authenticated pages work (Twitter, Gmail, etc.). Cookie access may not be granted; if authenticated browsing is unavailable, continue with public pages and local evidence instead of blocking. Use browser_read_text with a CSS selector to narrow down content on large pages.

When searching files via the terminal, prefer `rg` (ripgrep) over `grep`/`find` — it's installed, respects .gitignore, and is much faster. Use `rg --files <specific-dir> | rg <pattern>` to find files by name. Never search from filesystem root or all of `$HOME`; constrain searches to the project, logs, output, or another task-relevant directory.

Expand Down
46 changes: 46 additions & 0 deletions src/agent/cli_backends/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Pluggable CLI agent backends (Codex, Claude Code).

When ServerConfig.agent_backend != "gemini", the four stages (discover, promote,
memory ingest/lint, execute) shell out to one of these CLIs in lieu of running
our in-process Agent + tools loop. The CLIs ship their own agentic harness
with Read/Write/Bash built in, so we don't pass tool schemas and we don't use
sandbox-exec — filesystem scoping comes from `--sandbox workspace-write`
(Codex) and `--permission-mode bypassPermissions` + `--add-dir` (Claude).
"""

from .backend import (
AGENT_BACKENDS,
CliAgentAuthError,
CliAgentCancelled,
CliAgentError,
CliBackendConfig,
HARDCODED_CLAUDE_EFFORT,
HARDCODED_CLAUDE_MODEL,
HARDCODED_CODEX_MODEL,
HARDCODED_CODEX_REASONING_EFFORT,
cli_config_from_payload,
cli_config_payload,
is_cli_backend,
load_cli_config,
)
from .prompts import cli_footer, strip_tool_plumbing
from .stages import run_stage_via_cli

__all__ = [
"AGENT_BACKENDS",
"CliAgentAuthError",
"CliAgentCancelled",
"CliAgentError",
"CliBackendConfig",
"HARDCODED_CLAUDE_EFFORT",
"HARDCODED_CLAUDE_MODEL",
"HARDCODED_CODEX_MODEL",
"HARDCODED_CODEX_REASONING_EFFORT",
"cli_config_from_payload",
"cli_config_payload",
"cli_footer",
"is_cli_backend",
"load_cli_config",
"run_stage_via_cli",
"strip_tool_plumbing",
]
110 changes: 110 additions & 0 deletions src/agent/cli_backends/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Backend selection and shared types for CLI agent backends."""

from __future__ import annotations

import os
from dataclasses import dataclass, field
from typing import Any, Literal

AGENT_BACKENDS = ("gemini", "codex", "claude_code")
HARDCODED_CODEX_MODEL = "gpt-5.5"
HARDCODED_CODEX_REASONING_EFFORT = "xhigh"
HARDCODED_CLAUDE_MODEL = "claude-sonnet-4-6"
HARDCODED_CLAUDE_EFFORT = "high"


class CliAgentError(RuntimeError):
"""Raised when the CLI exits non-zero or expected outputs are missing."""


class CliAgentAuthError(CliAgentError):
"""Raised when the CLI reports an auth failure (missing/expired login)."""


class CliAgentCancelled(CliAgentError):
"""Raised when the run was cancelled via should_stop()."""


@dataclass(frozen=True)
class CliBackendConfig:
backend: Literal["codex", "claude_code"]
codex_bin: str
claude_bin: str
codex_model: str
codex_reasoning_effort: str
claude_model: str
claude_effort: str
# Subprocess-scoped env additions (PATH augmented with cli_bin_extra_path
# so binaries installed to a non-default npm prefix remain discoverable).
extra_env: dict[str, str] = field(default_factory=dict)


def is_cli_backend(name: str | None) -> bool:
return name in {"codex", "claude_code"}


def cli_config_payload(server_config: Any) -> dict | None:
"""Serialize a CliBackendConfig into a JSON-safe dict for the worker payload.

Returns None when the configured backend is gemini so callers can decide
whether to include the key in the payload at all.
"""
cfg = load_cli_config(server_config)
if cfg is None:
return None
return {
"backend": cfg.backend,
"codex_bin": cfg.codex_bin,
"claude_bin": cfg.claude_bin,
"codex_model": cfg.codex_model,
"codex_reasoning_effort": cfg.codex_reasoning_effort,
"claude_model": cfg.claude_model,
"claude_effort": cfg.claude_effort,
"extra_env": dict(cfg.extra_env),
}


def cli_config_from_payload(payload: dict | None) -> CliBackendConfig | None:
"""Rebuild a CliBackendConfig from the worker payload dict, or None."""
if not payload:
return None
return CliBackendConfig(
backend=payload["backend"],
codex_bin=payload.get("codex_bin", "codex"),
claude_bin=payload.get("claude_bin", "claude"),
codex_model=HARDCODED_CODEX_MODEL,
codex_reasoning_effort=HARDCODED_CODEX_REASONING_EFFORT,
claude_model=HARDCODED_CLAUDE_MODEL,
claude_effort=HARDCODED_CLAUDE_EFFORT,
extra_env=dict(payload.get("extra_env") or {}),
)


def load_cli_config(server_config: Any) -> CliBackendConfig | None:
"""Return CliBackendConfig when cfg.agent_backend is a CLI backend, else None.

The single signal stages need to decide whether to take the CLI branch.
"""
backend = getattr(server_config, "agent_backend", "gemini") or "gemini"
if not is_cli_backend(backend):
return None

extra_env: dict[str, str] = {}

# PATH augmentation so a binary installed to e.g. ~/.local/bin (via the
# install endpoint's --prefix fallback) stays discoverable on restart.
extra_path = (getattr(server_config, "cli_bin_extra_path", "") or "").strip()
if extra_path:
current_path = os.environ.get("PATH", "")
extra_env["PATH"] = f"{extra_path}:{current_path}" if current_path else extra_path

return CliBackendConfig(
backend=backend, # type: ignore[arg-type]
codex_bin=getattr(server_config, "codex_bin", "codex") or "codex",
claude_bin=getattr(server_config, "claude_bin", "claude") or "claude",
codex_model=HARDCODED_CODEX_MODEL,
codex_reasoning_effort=HARDCODED_CODEX_REASONING_EFFORT,
claude_model=HARDCODED_CLAUDE_MODEL,
claude_effort=HARDCODED_CLAUDE_EFFORT,
extra_env=extra_env,
)
34 changes: 34 additions & 0 deletions src/agent/cli_backends/claude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Build the claude CLI command. Mirrors bangers/scripts/discovery/providers.py."""

from __future__ import annotations

from pathlib import Path


def build_claude_command(
*,
claude_bin: str,
model: str,
effort: str,
bare: bool = False,
permission_mode: str = "bypassPermissions",
add_dirs: list[Path] | None = None,
max_turns: int | None = None,
stream_json: bool = True,
enable_browser: bool = False,
) -> list[str]:
"""Construct the `claude` argv. Prompt is delivered via stdin (`-p`)."""
cmd = [claude_bin]
if bare:
cmd.append("--bare")
if enable_browser:
cmd.append("--chrome")
cmd.extend(["--model", model, "--effort", effort, "--permission-mode", permission_mode])
if stream_json:
cmd.extend(["--output-format", "stream-json", "--verbose", "--include-partial-messages"])
if max_turns is not None:
cmd.extend(["--max-turns", str(max_turns)])
for d in add_dirs or []:
cmd.extend(["--add-dir", str(d)])
cmd.append("-p")
return cmd
44 changes: 44 additions & 0 deletions src/agent/cli_backends/codex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Build the codex CLI command. Mirrors bangers/scripts/discovery/providers.py."""

from __future__ import annotations

from pathlib import Path


def build_codex_command(
*,
codex_bin: str,
model: str,
reasoning_effort: str,
cwd: Path,
sandbox: str = "workspace-write",
ignore_user_config: bool = False,
enable_web_search: bool = False,
) -> list[str]:
"""Construct the `codex exec` argv. Prompt is delivered via stdin (`-`).

We deliberately do NOT use codex's `--output-schema` + `-o` flags. They
require strict JSON Schema (every property must be in `required`), which
fights Pydantic models that have default values. Instead we instruct the
agent to write the JSON file itself via its built-in Write tool, and rely
on the runner's `expected_outputs` poll to terminate the subprocess once
the file lands.
"""
cmd = [codex_bin]
if enable_web_search:
cmd.append("--search")
cmd.extend([
"exec",
"-m",
model,
"-c",
f'model_reasoning_effort="{reasoning_effort}"',
"--sandbox",
sandbox,
"--cd",
str(cwd),
])
if ignore_user_config:
cmd.append("--ignore-user-config")
cmd.append("-")
return cmd
Loading