Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ Day to day you only need `arbor`:
| Command | What it does |
| --- | --- |
| `arbor` | Start an interactive research session. |
| `arbor --continue` (`-C`) | Resume the most recent unfinished planning conversation in this directory (like `claude -c`). |
| `arbor replay --demo` | Replay a bundled sample run in the live dashboard — no API key needed. Add `--html` for a shareable browser page. |
| `arbor replay <session>` | Replay any past run's `events.jsonl` from its timeline (`--html` to export an interactive page). |
| `arbor quickstart` | Get running fast with a free key (Gemini/Groq) or a local model (Ollama). |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ ROOT(基线:20%)
| 命令 | 功能 |
| ------------------------ | ----------------------------------------------------------- |
| `arbor` | 启动交互式研究会话。 |
| `arbor --continue`(`-C`) | 续聊当前目录下最近一段未结束的规划对话(类似 `claude -c`)。 |
| `arbor replay --demo` | 在实时仪表盘里回放一次内置示例运行 —— 无需 API 密钥;加 `--html` 生成可分享的网页。 |
| `arbor replay <session>` | 按时间轴回放任意历史运行的 `events.jsonl`(`--html` 可导出交互式页面)。 |
| `arbor quickstart` | 用免费密钥(Gemini/Groq)或本地模型(Ollama)快速跑起来。 |
Expand Down
24 changes: 24 additions & 0 deletions docs/outputs-and-resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ Every experiment ran on its own git branch in an isolated worktree. Merged impro
are on trunk; explored-but-unmerged ideas remain as branch refs you can inspect. During a
run, `/branches` lists the explored branch refs and `/tree` prints the Idea Tree snapshot.

## Saving & resuming the planning conversation

The intake chat — what you do at the `arbor` prompt *before* a run launches — is itself
saved every turn, so you can step away and pick it back up. Each conversation lives in its
own directory under the launch folder:

```text
<cwd>/.arbor/conversations/<conv_id>/
```

with the message history (`messages.jsonl`) and a small `meta.json` (title, timestamps,
turn count). Saving is best-effort and never interrupts the chat.

Two ways back in:

- **`arbor --continue`** (`-C`) — reload the most recent *unfinished* conversation and keep
chatting, like `claude -c`. (`-c` is already `--config`, so the short flag is `-C`.)
- **`/resume`** inside intake — lists saved conversations (tagged `[chat]`) alongside
launched runs; pick a conversation to keep chatting, or a run to replay the engine.

On resume the prior exchange is replayed to the terminal so you can see what was already
said. Once a conversation launches a run it is recorded as launched and dropped from
`--continue` — the run's own checkpoint (below) takes over.

## Resuming an interrupted run

If a run is interrupted — you stopped it, the machine rebooted, a budget tripped — resume
Expand Down
22 changes: 22 additions & 0 deletions docs/outputs-and-resume.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ Arbor 记录一次运行产生的一切,让你能检视它、复现它,并
想法仍以分支 ref 的形式存在,供你检视。运行过程中,`/branches` 列出探索过的分支 ref,`/tree`
打印想法树快照。

## 保存与续聊规划对话

`arbor` 启动后、真正发起运行*之前*的那段规划对话,本身也会每轮自动保存,方便你离开后接着聊。
每段对话有自己的目录,位于启动目录下:

```text
<cwd>/.arbor/conversations/<conv_id>/
```

里面是消息历史(`messages.jsonl`)和一个小的 `meta.json`(标题、时间戳、轮数)。保存是尽力而为,
绝不会打断对话。

两种回到对话的方式:

- **`arbor --continue`**(`-C`)—— 重新载入最近一段*未结束*的对话并接着聊,类似 `claude -c`。
(`-c` 已经是 `--config`,所以短旗用 `-C`。)
- 在 intake 里输入 **`/resume`** —— 会把已保存的对话(标 `[chat]`)和已启动的运行一起列出;
选对话就继续聊,选运行就重放引擎。

续聊时会把之前的对话回放到终端,让你看到已经聊过的内容。一旦某段对话发起了运行,它会被标记为
已启动并从 `--continue` 中排除——接力交给运行自己的检查点(见下文)。

## 续跑被中断的运行

如果一次运行被中断——你停了它、机器重启、预算耗尽——从它的检查点续跑,而不是从头再来:
Expand Down
7 changes: 7 additions & 0 deletions src/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ def run_command(
help="Resume an interrupted run from its checkpoint (idea tree + message history) "
"in the existing workspace/session, instead of starting fresh.",
),
continue_latest: bool = typer.Option(
False, "--continue", "-C",
help="Continue the most recent unfinished intake conversation in this directory "
"(like `claude -c`). Reloads the planning chat so you pick up where you left off. "
"(-c is --config; use -C or --continue.)",
),
workspace_dir: Path | None = typer.Option(
None, "--workspace-dir",
help=f"Session/artifact directory override. Default: <target>/{CONFIG_DIR_NAME}/sessions/<run_name>.",
Expand Down Expand Up @@ -243,6 +249,7 @@ def _probe_config() -> CoordinatorConfig:
starting_cwd=starting_cwd,
seed_message=instruction,
intake_max_turns=intake_max_turns,
continue_latest=continue_latest,
))
except KeyboardInterrupt:
typer.secho("\n^C — aborted before run started", fg=typer.colors.YELLOW, err=True)
Expand Down
240 changes: 240 additions & 0 deletions src/cli/intake/conversation_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
"""Persist the pre-launch intake conversation so it can be resumed.

The intake REPL (``arbor`` with no subcommand) is a planning chat held *before*
a research run is launched. Until now that chat lived only in memory: quit
before launching and it was gone, so there was nothing to ``/resume``. Launched
runs persist under ``.arbor/sessions/<run_name>/`` (see
:mod:`arbor.coordinator.checkpoint`); this module is the analogous, deliberately
lighter store for the *conversation* itself.

Layout, one dir per conversation under the launch directory::

<cwd>/.arbor/conversations/<conv_id>/
messages.jsonl # the agent's message history (atomic JSONL IO)
meta.json # id, timestamps, title, turn count, launched flag

A conversation is **unfinished** while ``launched`` is false. Once the chat
fires ``LaunchExperiment`` the run's own checkpoint takes over, so the record is
marked ``launched`` and excluded from ``--continue`` (it is kept as history).

Message IO is reused verbatim from the checkpoint contract
(:func:`arbor.coordinator.checkpoint.write_messages` /
:func:`~arbor.coordinator.checkpoint.read_messages`) so a conversation and a run
serialize their histories identically. Meta writes are atomic (temp + replace)
for the same reason checkpoints are: an interrupt must never leave a torn file.
"""

from __future__ import annotations

import json
import os
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from ..._app import CONFIG_DIR_NAME
from ...coordinator.checkpoint import read_messages, write_messages

#: Bump when the on-disk meta shape changes incompatibly.
SCHEMA_VERSION = 1

CONVERSATIONS_DIRNAME = "conversations"
MESSAGES_NAME = "messages.jsonl"
META_NAME = "meta.json"

_TITLE_MAX = 80


def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()


def conversations_root(cwd: str | os.PathLike[str]) -> Path:
"""Return ``<cwd>/.arbor/conversations`` (the per-project conversation root)."""
return Path(cwd) / CONFIG_DIR_NAME / CONVERSATIONS_DIRNAME


@dataclass
class ConversationRecord:
"""One saved intake conversation, addressed by ``conv_id`` under ``cwd``."""

conv_id: str
cwd: Path
created_at: str
updated_at: str
title: str = ""
turns: int = 0
launched: bool = False

@property
def dir(self) -> Path:
return conversations_root(self.cwd) / self.conv_id

@property
def messages_path(self) -> Path:
return self.dir / MESSAGES_NAME

@property
def meta_path(self) -> Path:
return self.dir / META_NAME

def to_meta(self) -> dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"conv_id": self.conv_id,
"created_at": self.created_at,
"updated_at": self.updated_at,
"title": self.title,
"turns": self.turns,
"launched": self.launched,
}

@classmethod
def from_meta(cls, cwd: Path, data: dict[str, Any]) -> "ConversationRecord":
return cls(
conv_id=str(data["conv_id"]),
cwd=Path(cwd),
created_at=str(data.get("created_at") or ""),
updated_at=str(data.get("updated_at") or data.get("created_at") or ""),
title=str(data.get("title") or ""),
turns=int(data.get("turns") or 0),
launched=bool(data.get("launched", False)),
)


def new_conversation(cwd: str | os.PathLike[str]) -> ConversationRecord:
"""Mint a fresh record (timestamp-derived id). Does NOT touch disk.

The id is unique even within the same second: if a directory already
exists, a numeric suffix is appended.
"""
now = datetime.now()
base = now.strftime("conv_%Y%m%d_%H%M%S")
root = conversations_root(cwd)
conv_id = base
n = 1
while (root / conv_id).exists():
n += 1
conv_id = f"{base}_{n}"
iso = _utc_now_iso()
return ConversationRecord(
conv_id=conv_id, cwd=Path(cwd), created_at=iso, updated_at=iso
)


def save_conversation(
rec: ConversationRecord,
messages: list[dict[str, Any]],
*,
launched: bool = False,
) -> None:
"""Persist ``messages`` + refreshed meta for ``rec`` atomically.

Updates ``rec`` in place (``updated_at``, ``turns``, ``title``, ``launched``)
so the caller's handle stays current across repeated saves.
"""
rec.dir.mkdir(parents=True, exist_ok=True)
write_messages(rec.messages_path, messages)

rec.updated_at = _utc_now_iso()
rec.turns = _count_user_turns(messages)
rec.launched = launched
if not rec.title:
rec.title = _derive_title(messages)

_atomic_write_json(rec.meta_path, rec.to_meta())


def load_messages(rec: ConversationRecord) -> list[dict[str, Any]]:
"""Load the saved message history (``[]`` if none), tolerant of corruption."""
return read_messages(rec.messages_path)


def find_conversations(cwd: str | os.PathLike[str]) -> list[ConversationRecord]:
"""Return all readable conversations under ``cwd``, newest update first.

Defensive: a dir without a parseable ``meta.json`` is skipped, never fatal.
"""
root = conversations_root(cwd)
if not root.is_dir():
return []

records: list[ConversationRecord] = []
for conv_dir in root.iterdir():
if not conv_dir.is_dir():
continue
data = _load_json(conv_dir / META_NAME)
if not isinstance(data, dict) or "conv_id" not in data:
continue
try:
records.append(ConversationRecord.from_meta(Path(cwd), data))
except (KeyError, ValueError, TypeError):
continue

records.sort(key=lambda r: r.updated_at, reverse=True)
return records


def latest_unfinished(cwd: str | os.PathLike[str]) -> ConversationRecord | None:
"""Return the newest non-launched conversation with real content, or None."""
for rec in find_conversations(cwd):
if not rec.launched and rec.turns > 0 and rec.messages_path.is_file():
return rec
return None


# ── helpers ──────────────────────────────────────────────────────────


def _count_user_turns(messages: list[dict[str, Any]]) -> int:
return sum(1 for m in messages if m.get("role") == "user")


def _derive_title(messages: list[dict[str, Any]]) -> str:
"""First user message, flattened to a short single line."""
for m in messages:
if m.get("role") != "user":
continue
text = _message_text(m.get("content"))
text = " ".join(text.split())
if text:
return text if len(text) <= _TITLE_MAX else text[: _TITLE_MAX - 1] + "…"
return ""


def _message_text(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
b.get("text", "")
for b in content
if isinstance(b, dict) and isinstance(b.get("text"), str)
]
return " ".join(p for p in parts if p)
return ""


def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False)
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fp:
fp.write(payload)
fp.flush()
os.fsync(fp.fileno())
os.replace(tmp, path)
except BaseException:
tmp.unlink(missing_ok=True)
raise


def _load_json(path: Path) -> Any | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
4 changes: 4 additions & 0 deletions src/cli/intake/launch_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ class LaunchState:
# Set when the user runs `/resume` and picks a past session. Typed Any to
# avoid importing resume_picker here; it holds a ``ResumableSession``.
resume_target: Any = None
# Set when `/resume` picks a saved *conversation* (not a launched run): the
# chosen ``ConversationRecord``. The REPL reloads it into the live agent and
# keeps chatting, persisting onward saves to this same record.
resume_conversation: Any = None

@property
def launched(self) -> bool:
Expand Down
Loading
Loading