Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ OLLAMA_MODEL=qwen2.5:3b # reasoning
OLLAMA_VISION_MODEL=qwen2.5vl:7b # vision (needs substantial RAM)
OLLAMA_EMBED_MODEL=nomic-embed-text # embeddings
OLLAMA_EMBED_DIM=768 # set if OLLAMA_EMBED_MODEL is not 768-dim
# OLLAMA_VISION_TIMEOUT_MS=300000 # abort bound for vision calls (qwen2.5vl is slow: 40-150s is normal)

# --- Executor ---
RORO_WORKDIR=/abs/path/to/scratch-git-repo # explicit dev repo; packaged builds use Choose Project
RORO_WORKDIR=/abs/path/to/scratch-git-repo # dev repo when no project is saved in-app (the saved choice wins)
# RORO_ALLOW_CWD=1 # optional local-dev opt-in to use the launch cwd
ANTHROPIC_API_KEY= # optional, only for the Claude executor
# RORO_CODEX_BIN= # optional override for the codex binary path
Expand Down
4 changes: 2 additions & 2 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The W7 split (#149) put **all product logic in `src/core/`** (Electron-free) wit
| `src/core/orchestrator/` | The **turnRun chokepoint**, now a typed state machine. `orchestrator.ts` = the facade (`runTurn`/`runTask`/`cancelTask`/`resolveDestructiveConfirm`); `run/` = the **Turn machine** (`turnState.ts` — UI truth) + **Pump machine** (`pump.ts` — process truth) + `runRegistry.ts` (live Turns + the single-executor **slot** + the `DispatchSection` TOCTOU-lock-as-type) + `gates.ts` (the pre-dispatch pipeline) + `decisionRouter.ts` (the bounded two-pass decide loop). Plus `siblings.ts` (typed lazy brain/memory/vision loaders), `factStore.ts` (supersede-not-overwrite), `factProposals/` (the executor-facts pilot), `confirmGate.ts`, `destructive.ts`, `workdir.ts`, `identity.ts` (owner_id), `memoryContext.ts`. |
| `src/core/brain/` | Local brain. `index.ts` (decide/extractFact/embed; `BRAIN_PROVIDER`≠`'ollama'` throws), `extractFact.ts` (marker gate + parser + value guard), `clarifyGate.ts`/`locateGate.ts`, `ollama.ts`, `eval/` (the eval harness: 50 behavioral fixtures + null-discipline axis + the `eval:proposals` runner + `baseline.json`). |
| `src/core/memory2/` | Memory engine. `index.ts` (`createMemoryFacade` — the memory surface — + `traceExtraction`), `memoryStore.ts` (files-as-truth + reconcile), `memIndex.ts` (in-memory index behind `IndexStore`) + `vectorCache.ts` (the zero-authority `index/vectors.jsonl` sidecar), `manifestCompact.ts` (compaction **with** the fact-tier exemption), `profileFacts.ts` (correction/source view + executor-proposal provenance pass-through), `keyManager.ts`/`safeStorageWrapper.ts`/`cipher.ts` (envelope encryption), `tracer.ts`, `memoryScore.ts` (recall blend). The old `adapter.ts` + `pgliteIndex.ts` are **deleted**; the cursor is ephemeral (every open replays the manifest from files). |
| `src/core/executor/` | Coding-agent dispatch (edits files in `RORO_WORKDIR`). Default = the `codex` + `claude` **CLI** adapters (`codex.ts`, `claude.ts`). A **dark, flag-gated** (`RORO_SDK_EXECUTOR`) **Agent-SDK** adapter (`claudeSdk.ts`) adds **pre-execution** destructive gating (`claudeSdkGate.ts`: a PreToolUse hook + `canUseTool`, memoized per `toolUseId`). |
| `src/core/executor/` | Coding-agent dispatch (edits files in the configured project — the persisted in-app choice, else `RORO_WORKDIR`). Default = the `codex` + `claude` **CLI** adapters (`codex.ts`, `claude.ts`). A **dark, flag-gated** (`RORO_SDK_EXECUTOR`) **Agent-SDK** adapter (`claudeSdk.ts`) adds **pre-execution** destructive gating (`claudeSdkGate.ts`: a PreToolUse hook + `canUseTool`, memoized per `toolUseId`). |
| `src/core/vision/` | Screen capture describe/locate (sharp + vision model). |
| `src/core/ambient/` | The **dormant, gated-off** ambient track (belief-latch + eye + trigger). |
| `src/renderer/` | PixiJS pixel cat + UI. `character/`, `ask/`, `memory/` (the correction panel + the fact-proposal confirm), `voice/` (a thin client of `packages/voice`), `bootstrap/`, `cosmetics/`, `confirm/`, `events/bridge.ts`. |
Expand Down Expand Up @@ -292,7 +292,7 @@ memory/embeddings). Add new expensive lessons there, not here.
- **Don't commit `.env`** (gitignored). Historical predecessor/demo checkouts are archived context only and must not be edited as part of Roro work.
- **Verify before claiming done** — "the types check" is not done; "I observed it working" is.
- **Key commands:** `npm test`, `npm run lint`, `npx tsc --noEmit -p tsconfig.json`, `npm run release:doctor` (CI-safe release/signing doctor), `npm run package` (.app), `npm run verify:floating-geometry` (default 360x420 transparent click-through pet shell), `npm run package:release` + `npm run verify:release-channel` (release-channel deferred-flag refusal), `npm run verify:packaged-memory` (packaged write/relaunch/recall), `npm run verify:packaged-live-memory-turn` (packaged relaunch + live Ollama turn uses recalled memory), `npm run verify:packaged-natural-memory-turn` (packaged natural-language teach/relaunch/recall), `npm run verify:packaged-real-codex` (opt-in packaged first task with the user's real authenticated/configured Codex CLI), `npm run verify:memory-steered` (synthetic-marker DECIDE/args.task proof), `npm run verify:memory-panel-rendered` (opt-in local GUI smoke for rendered Memory panel keyboard/focus behavior), `npm run verify:signing-readiness` (strict Developer-ID env/cert/tool doctor), `npm run verify:signing-auth` (notarytool Apple credential auth check), `npm run make` (+ distributables + signing), `npm run verify:release-artifact:dmg` (post-make DMG verifier), `npm run verify:release-artifact:signed` (post-make signed/notarized artifact verifier), `npm start` (dev — memory works here), `OLLAMA_AVAILABLE=1 npx vitest run crosslaunch.live` (live magic-moment smoke), `npm run eval:brain` (scorecard), `EVAL_SET=behavioral npm run eval:brain`.
- **State lives in:** memory + owner.json + packaged config → `app.getPath('userData')` (override `RORO_DB_DIR`). The agent's working repo resolves from explicit `RORO_WORKDIR`, then persisted `userData/config.json`, then the explicit `RORO_ALLOW_CWD=1` dev fallback.
- **State lives in:** memory + owner.json + packaged config → `app.getPath('userData')` (override `RORO_DB_DIR`). The agent's working repo resolves from the persisted in-app choice (`userData/config.json`), then the `RORO_WORKDIR` env var, then the explicit `RORO_ALLOW_CWD=1` cwd fallback (dev).

---

Expand Down
9 changes: 5 additions & 4 deletions PUBLIC.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ Roro is public-ready when **all** of these are observed (not code-read):
no shell for the Roro setup path. Executor CLIs may still require their own local auth, separate from Roro's
brain/model key promise.
- [ ] The chosen working repo persists in `userData/config.json` and survives relaunch; the executor never throws
"Roro has no working repo set" for a user who completed onboarding. `RORO_WORKDIR` remains an explicit env override.
"Roro has no working repo set" for a user who completed onboarding. `RORO_WORKDIR` applies only when no project
has been chosen in-app (the persisted choice wins).
- [ ] **The heart:** on the signed build, a fact stored in session 1 is recalled in session 2 after a **full quit +
relaunch** — `safeStorage.isEncryptionAvailable()` is true, the AES-256-GCM envelope round-trips.
- [ ] **A non-founder** observes the magic moment: types a task, Roro recalls a prior fact and **uses it to shape the
Expand Down Expand Up @@ -159,8 +160,8 @@ sharpened magic-moment gate above) across a full quit/relaunch of a packaged bui
the happy packaged first-run workdir flow.

**Goal:** take a stranger from launch → a successful coding turn, no shell. *(All memory-architecture-independent.)*
- ✅ `userData/config.json` read/write for the working repo; `resolveWorkdir` honors explicit env first, then persisted
config, then the explicit `RORO_ALLOW_CWD=1` dev fallback.
- ✅ `userData/config.json` read/write for the working repo; `resolveWorkdir` honors persisted config first, then
explicit env, then the explicit `RORO_ALLOW_CWD=1` dev fallback.
- ✅ First-run flow: no workdir → native folder-picker ("Which project should Roro work on?") → persist; typed and floating
tasks are gated until a project exists.
- ✅ Raw "Roro has no working repo set" is no longer the normal user path; the UI asks for a project before dispatch.
Expand All @@ -171,7 +172,7 @@ the happy packaged first-run workdir flow.
- ✅ Packaged model-setup preflight: `npm run verify:packaged-model-setup` proves reachable-Ollama/missing-model setup
plus daemon-down → Recheck recovery in the packaged app, then completes the essential model pull path.
- ✅ Project control in Settings: after first setup, the user can see the active repo and change the saved project
without relaunching; `RORO_WORKDIR` remains an explicit read-only override.
without relaunching; `RORO_WORKDIR` applies only when no project is saved (the saved project wins).
- ✅ Packaged first-task preflight: `npm run verify:packaged-first-task` proves the persisted project, local-brain
readiness, public selected-executor readiness bridge, and public `turnRun` path can produce a file change in the chosen
repo without debug bridges.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ npm start

In a packaged build, Roro asks you to choose the project folder it should work
on, stores that choice in `userData/config.json`, and reuses it after relaunch.
In development, `RORO_WORKDIR` is still the fastest explicit override.
The working repo resolves from the persisted in-app choice first, then the
`RORO_WORKDIR` env var, then the explicit `RORO_ALLOW_CWD=1` cwd fallback (dev).
If Roro says no working repo is set, choose a project in Settings or relaunch
with `RORO_WORKDIR=/absolute/path/to/repo npm start`; blank or stale paths are
treated as unset so the executor never runs against a guessed directory.
Expand Down Expand Up @@ -169,7 +170,8 @@ OLLAMA_VISION_MODEL=qwen2.5vl:7b
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_EMBED_DIM=768 # set this if OLLAMA_EMBED_MODEL is not 768-dim

# Optional dev override. Packaged first-run uses the native Choose Project flow.
# Optional dev fallback (a project saved in-app wins over this). Packaged
# first-run uses the native Choose Project flow.
RORO_WORKDIR=/absolute/path/to/scratch-git-repo
ANTHROPIC_API_KEY=... # optional, only for the Claude executor
```
Expand Down
4 changes: 2 additions & 2 deletions RUN.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ OLLAMA_VISION_MODEL=qwen2.5vl:7b
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_EMBED_DIM=768 # set this if OLLAMA_EMBED_MODEL is not 768-dim

RORO_WORKDIR=/abs/path/to/scratch-git-repo # the repo the agent actually codes in
RORO_WORKDIR=/abs/path/to/scratch-git-repo # the repo the agent codes in (a project saved in-app wins over this)
ANTHROPIC_API_KEY=... # only if you use the Claude executor
```

Expand Down Expand Up @@ -109,7 +109,7 @@ npm start
```

Summon (Cmd+Shift+Space) → type a task in the floating Ask (or the dev prompt box) → the
cat thinks (driven by the local brain's content stream) → drives Codex in `RORO_WORKDIR`
cat thinks (driven by the local brain's content stream) → drives Codex in the configured project (the in-app choice, else `RORO_WORKDIR`)
(each action narrated + animated) → writes to local encrypted memory. Then ask
*"what did we do?"* for the semantic-recall beat.

Expand Down
3 changes: 3 additions & 0 deletions scripts/smoke-floating-live-turn.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,9 @@ await writeFakeCodexBin(fakeCodexBin, fakeCodexArgsFile, fakeCodexActiveStopMark
const fakeOllama = USE_REAL_OLLAMA ? null : await startFakeOllama();
const appEnv = stripV0DeferredEnv({
...process.env,
// Disposable HOME → fresh userData: a persisted config.json on the dev machine would win over
// RORO_WORKDIR (the deliberate precedence reversal after the first dogfooding session).
HOME: root,
BRAIN_PROVIDER: 'ollama',
...(fakeOllama ? { OLLAMA_HOST: fakeOllama.host, OLLAMA_TIMEOUT_MS: '5000' } : {}),
RORO_DEBUG_PORT: PORT,
Expand Down
3 changes: 3 additions & 0 deletions scripts/smoke-memory-panel-rendered.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const root = await mkdtemp(join(tmpdir(), 'roro-memory-panel-rendered-'));
const inheritedEnv = stripV0DeferredEnv({ ...process.env });
const appEnv = {
...inheritedEnv,
// Disposable HOME → fresh userData: a persisted config.json on the dev machine would win over
// RORO_WORKDIR (the deliberate precedence reversal after the first dogfooding session).
HOME: root,
RORO_DEBUG_PORT: PORT,
RORO_MEMORY_PANEL_SMOKE: '1',
RORO_DISABLE_MEMORY_WARMUP: '1',
Expand Down
10 changes: 6 additions & 4 deletions src/core/brain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,12 @@ export async function extractFact(input: FactExtractInput): Promise<FactCandidat
}

// Local vision (qwen2.5-VL) is far slower than the reason model — a caption/grounding call can take
// minutes on a cold model load or a slow machine. Give vision calls a generous per-call timeout (well
// above the 120s reason-model default, overridable) so a valid-but-slow locate draws the paw instead of
// failing with a chat timeout. See docs/plans/paw-on-the-pixel-HANDOFF.md (vision latency).
function visionTimeoutMs(): number {
// minutes on a cold model load or a slow machine (40-150s per call is normal). Give vision calls a
// generous per-call timeout (well above the 120s reason-model default, overridable via
// OLLAMA_VISION_TIMEOUT_MS) so a valid-but-slow call completes — while a truly hung daemon still
// ABORTS (typed OllamaTimeoutError → the router's run.failed) instead of hanging the turn forever.
// Exported so the generous default stays pinned by test. See docs/plans/paw-on-the-pixel-HANDOFF.md.
export function visionTimeoutMs(): number {
const v = Number(process.env.OLLAMA_VISION_TIMEOUT_MS);
return Number.isFinite(v) && v > 0 ? v : 300_000;
}
Expand Down
65 changes: 65 additions & 0 deletions src/core/brain/index.visionTimeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describeScreen, groundTarget, visionTimeoutMs } from './index';
import { OllamaTimeoutError } from './ollama';

// Dogfooding fix: a wedged/slow qwen2.5vl call must never hang a capture_screen turn — the live
// vision calls carry an ABORTING per-call timeout (OLLAMA_VISION_TIMEOUT_MS, generous 300s default:
// 40-150s per call is NORMAL for the local 7B) and surface the typed OllamaTimeoutError, which the
// decision router turns into run.failed ('vision failed: ...') — never a silent hang, never a
// fabricated success.

// A fetch that NEVER resolves but honors AbortSignal — a daemon that connected then wedged.
const hangingFetch = (_url: string, opts?: { signal?: AbortSignal }): Promise<Response> =>
new Promise((_resolve, reject) => {
const signal = opts?.signal;
signal?.addEventListener('abort', () => reject(signal.reason));
});

const screen = { b64: 'aGk=', mime: 'image/jpeg', width: 1280, height: 720 };

describe('vision call timeout (a hung daemon must fail the turn, not hang it)', () => {
beforeEach(() => {
delete process.env.BRAIN_PROVIDER; // force the local Ollama path
});

afterEach(() => {
vi.unstubAllGlobals();
delete process.env.OLLAMA_VISION_TIMEOUT_MS;
delete process.env.OLLAMA_TIMEOUT_MS;
});

it('defaults to a generous 300s bound (vision is SLOW; 40-150s per call is normal)', () => {
expect(visionTimeoutMs()).toBe(300_000);
});

it('honors the OLLAMA_VISION_TIMEOUT_MS override and ignores garbage', () => {
process.env.OLLAMA_VISION_TIMEOUT_MS = '45000';
expect(visionTimeoutMs()).toBe(45_000);
process.env.OLLAMA_VISION_TIMEOUT_MS = 'not-a-number';
expect(visionTimeoutMs()).toBe(300_000);
process.env.OLLAMA_VISION_TIMEOUT_MS = '-1';
expect(visionTimeoutMs()).toBe(300_000);
});

it('describeScreen aborts a wedged vision call at the VISION bound and surfaces the typed error', async () => {
process.env.OLLAMA_TIMEOUT_MS = '600000'; // reason-model default must not be what fires or is reported
process.env.OLLAMA_VISION_TIMEOUT_MS = '30';
vi.stubGlobal('fetch', hangingFetch);

const err = await describeScreen(screen).catch((e: unknown) => e);

expect(err).toBeInstanceOf(OllamaTimeoutError);
expect((err as OllamaTimeoutError).timeoutMs).toBe(30);
expect((err as Error).message).toMatch(/timed out after 30ms/i);
});

it('groundTarget (the locate fast path) aborts at the same vision bound, typed', async () => {
process.env.OLLAMA_VISION_TIMEOUT_MS = '30';
vi.stubGlobal('fetch', hangingFetch);

const err = await groundTarget(screen, 'the failing test').catch((e: unknown) => e);

expect(err).toBeInstanceOf(OllamaTimeoutError);
expect((err as OllamaTimeoutError).timeoutMs).toBe(30);
});
});
61 changes: 61 additions & 0 deletions src/core/brain/ollama.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
assertEmbedDimMatch,
ollamaChat,
ollamaTags,
OllamaTimeoutError,
parsePullProgress,
} from './ollama';

Expand Down Expand Up @@ -133,9 +134,69 @@ describe('ollama fetch timeout (a wedged daemon must fail, not hang)', () => {
await expect(ollamaChat({ model: 'm', user: 'hi', stream: false })).rejects.toThrow(/timed out/i);
});

it('a wedged daemon surfaces the TYPED OllamaTimeoutError carrying the effective bound', async () => {
process.env.OLLAMA_TIMEOUT_MS = '20';
vi.stubGlobal('fetch', hangingFetch);
const err = await ollamaChat({ model: 'm', user: 'hi', stream: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(OllamaTimeoutError);
expect((err as OllamaTimeoutError).timeoutMs).toBe(20);
});

// Dogfooding fix: a per-call override (the slow vision model) must abort at ITS bound and REPORT
// that bound — the old message always claimed the 120s OLLAMA_TIMEOUT_MS default, misdiagnosing a
// 300s vision timeout and pointing at the wrong env remedy.
it('a per-call timeoutMs override aborts at its own bound and reports THAT bound, typed', async () => {
process.env.OLLAMA_TIMEOUT_MS = '600000'; // the default must be neither what fires nor what is reported
vi.stubGlobal('fetch', hangingFetch);
const err = await ollamaChat({ model: 'v', user: 'see', images: ['abc'], stream: false, timeoutMs: 25 })
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(OllamaTimeoutError);
expect((err as OllamaTimeoutError).timeoutMs).toBe(25);
expect((err as Error).message).toMatch(/timed out after 25ms/i);
});

it('ollamaTags rejects with a TIMEOUT error when /api/tags wedges', async () => {
process.env.OLLAMA_TIMEOUT_MS = '20';
vi.stubGlobal('fetch', hangingFetch);
await expect(ollamaTags()).rejects.toThrow(/timed out/i);
});

// The abort can also fire AFTER headers arrive (the daemon sends headers then wedges): the rejection
// then surfaces from res.json()/reader.read(), NOT fetch(). It must be the same TYPED error carrying
// the bound that actually fired — not a raw untyped 'TimeoutError' with no bound and no remedy.
const timeoutAbort = (): Error =>
Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' });

it('a post-header stall on the stream:false body (res.json) surfaces the TYPED error + bound', async () => {
vi.stubGlobal('fetch', async (): Promise<unknown> => ({
ok: true,
body: null,
json: () => Promise.reject(timeoutAbort()),
}));
const err = await ollamaChat({ model: 'v', user: 'see', images: ['abc'], stream: false, timeoutMs: 25 })
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(OllamaTimeoutError);
expect((err as OllamaTimeoutError).timeoutMs).toBe(25);
});

it('a post-header stall mid-stream (reader.read) surfaces the TYPED error + bound', async () => {
vi.stubGlobal('fetch', async (): Promise<unknown> => ({
ok: true,
body: { getReader: () => ({ read: () => Promise.reject(timeoutAbort()) }) },
}));
const err = await ollamaChat({ model: 'm', user: 'hi', stream: true, timeoutMs: 30 }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(OllamaTimeoutError);
expect((err as OllamaTimeoutError).timeoutMs).toBe(30);
});

it('a non-timeout body failure propagates untouched (never masked as a timeout)', async () => {
vi.stubGlobal('fetch', async (): Promise<unknown> => ({
ok: true,
body: null,
json: () => Promise.reject(new Error('bad json')),
}));
const err = await ollamaChat({ model: 'm', user: 'hi', stream: false }).catch((e: unknown) => e);
expect(err).not.toBeInstanceOf(OllamaTimeoutError);
expect((err as Error).message).toBe('bad json');
});
});
Loading
Loading