dsh-open-dream-rsi 0.2.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,8 +40,8 @@ directory holding your `tasks.json`:
40
40
 
41
41
  ```bash
42
42
  # from the npm registry, a tarball, a checkout, or straight from GitHub — pick one:
43
- dsh plugin --profile <name> add dsh-open-dream-rsi # or pin: dsh-open-dream-rsi@0.2.0
44
- dsh plugin --profile <name> add /path/to/dsh-open-dream-rsi-0.2.0.tgz
43
+ dsh plugin --profile <name> add dsh-open-dream-rsi # or pin: dsh-open-dream-rsi@0.2.1
44
+ dsh plugin --profile <name> add /path/to/dsh-open-dream-rsi-0.2.1.tgz
45
45
  dsh plugin --profile <name> add /path/to/open-dream-rsi/plugins/deepseek_harness
46
46
  dsh plugin --profile <name> add github:patrykorwat/open-dream-rsi#path:plugins/deepseek_harness
47
47
 
@@ -46,7 +46,7 @@ from open_dream_rsi.bench_policy import (
46
46
  run_policy_benchmark,
47
47
  )
48
48
 
49
- __version__ = "0.2.0"
49
+ __version__ = "0.2.1"
50
50
 
51
51
  __all__ = [
52
52
  "DiscoveryTree",
@@ -18,6 +18,9 @@ from open_dream_rsi.memory import DreamMemory
18
18
 
19
19
 
20
20
  def _build_client(provider: str, model: str | None):
21
+ if provider == "cursor-agent":
22
+ from open_dream_rsi.cursor_agent import CursorAgentClient
23
+ return CursorAgentClient(model=model)
21
24
  from open_dream_rsi.llm import LLMConfig, OpenAICompatibleClient
22
25
 
23
26
  cfg = LLMConfig.from_preset(provider) # honours OPENAI_BASE_URL / ODR_LLM_MODEL
@@ -181,6 +184,38 @@ def cmd_sessions(args: argparse.Namespace) -> int:
181
184
  return 0
182
185
 
183
186
 
187
+ def cmd_task(args: argparse.Namespace) -> int:
188
+ """Queue tasks without MCP: append_task writes the same tasks.json the
189
+ loop/dream cycle consumes, so hooks or a terminal can feed the loop on
190
+ hosts where MCP servers are unavailable (e.g. blocked by policy)."""
191
+ from open_dream_rsi.loop import append_task
192
+
193
+ tasks_file = (Path(args.tasks) if args.tasks
194
+ else Path(args.memory) / "tasks.json")
195
+ if args.task_cmd == "add":
196
+ task = {"task_id": args.id, "category": args.category,
197
+ "prompt": args.prompt, "tests": [],
198
+ "max_attempts": args.attempts}
199
+ if args.criteria:
200
+ task["criteria"] = args.criteria
201
+ try:
202
+ result = append_task(tasks_file, task)
203
+ except ValueError as exc:
204
+ print(f"error: {exc}", file=sys.stderr)
205
+ return 2
206
+ print(json.dumps(result, ensure_ascii=False))
207
+ return 0
208
+ tasks = (json.loads(tasks_file.read_text(encoding="utf-8"))
209
+ if tasks_file.exists() else [])
210
+ for t in tasks:
211
+ marker = ("criteria: " + t["criteria"][:60] if t.get("criteria")
212
+ else f"{len(t.get('tests') or [])} test(s)")
213
+ print(f"{t.get('task_id')}: [{t.get('category')}] "
214
+ f"attempts={t.get('max_attempts', 4)} {marker}")
215
+ print(f"({len(tasks)} task(s) in {tasks_file})")
216
+ return 0
217
+
218
+
184
219
  def cmd_dream(args: argparse.Namespace) -> int:
185
220
  """Automated cycle: evidence -> candidates -> promotion gate -> skills.
186
221
 
@@ -189,12 +224,20 @@ def cmd_dream(args: argparse.Namespace) -> int:
189
224
  cost), so any trigger — a Hermes hook, the MCP tool, or this CLI —
190
225
  can fire as often as it likes without overrunning the budget."""
191
226
  from open_dream_rsi.dream import dream_once
227
+ from open_dream_rsi.sessions import default_cursor_user_dir
192
228
 
193
229
  client = None
194
230
  if not args.offline:
195
231
  client = _build_client(args.provider, args.model)
232
+ if args.source == "cursor":
233
+ # the Cursor store is a User DIR (state.vscdb inside), not a db file
234
+ sessions_db = args.sessions or args.cursor_user_dir \
235
+ or str(default_cursor_user_dir())
236
+ else:
237
+ sessions_db = args.sessions
196
238
  report = dream_once(
197
- args.memory, sessions_db=args.sessions, skills_out=args.skills_out,
239
+ args.memory, sessions_db=sessions_db, session_source=args.source,
240
+ skills_out=args.skills_out,
198
241
  client=client, budget=args.budget, max_tokens=args.max_tokens,
199
242
  category=args.category)
200
243
  print(json.dumps(report, indent=2, ensure_ascii=False))
@@ -298,7 +341,7 @@ def main(argv: list[str] | None = None) -> int:
298
341
  loop = sub.add_parser("loop", help="run the self-improvement cycle")
299
342
  loop.add_argument("--tasks", required=True, help="JSON file: list of Task dicts")
300
343
  loop.add_argument("--provider", default="openai",
301
- choices=["openai", "cursor", "local", "goose"],
344
+ choices=["openai", "cursor-agent", "cursor", "local", "goose"],
302
345
  help="LLM endpoint; 'goose' borrows goose's own "
303
346
  "provider/model/key from ~/.config/goose")
304
347
  loop.add_argument("--model", default=None)
@@ -329,12 +372,18 @@ def main(argv: list[str] | None = None) -> int:
329
372
  "maintenance, skills publish; safe to fire from any trigger "
330
373
  "(cadence is decided internally, not by the caller)")
331
374
  dr.add_argument("--sessions", default=None,
332
- help="host state.db to import evidence from "
333
- "(e.g. ~/.hermes/state.db)")
375
+ help="host session store to import evidence from "
376
+ "(hermes: state.db path; cursor: the User dir)")
377
+ dr.add_argument("--source", default="hermes", choices=["hermes", "cursor"],
378
+ help="kind of the --sessions store (default hermes; "
379
+ "cursor reads state.vscdb directly, no MCP needed)")
380
+ dr.add_argument("--cursor-user-dir", default=None,
381
+ help="Cursor User dir for --source cursor (default: the "
382
+ "platform location, e.g. %%APPDATA%%\\Cursor\\User)")
334
383
  dr.add_argument("--skills-out", default=None,
335
384
  help="render ACTIVE lessons here as SKILL.md files")
336
385
  dr.add_argument("--provider", default="openai",
337
- choices=["openai", "cursor", "local", "goose"])
386
+ choices=["openai", "cursor-agent", "cursor", "local", "goose"])
338
387
  dr.add_argument("--model", default=None)
339
388
  dr.add_argument("--category", default=None,
340
389
  help="force one KB category (default: per-episode cwd)")
@@ -345,6 +394,26 @@ def main(argv: list[str] | None = None) -> int:
345
394
  help="no LLM: lifecycle maintenance only")
346
395
  dr.set_defaults(func=cmd_dream)
347
396
 
397
+ task = sub.add_parser(
398
+ "task", help="queue tasks without MCP (the file the loop consumes)")
399
+ task_sub = task.add_subparsers(dest="task_cmd", required=True)
400
+ ta = task_sub.add_parser("add", help="append one task to the queue file")
401
+ ta.add_argument("--id", required=True, help="unique task id")
402
+ ta.add_argument("--category", required=True,
403
+ help="KB category (usually the project name)")
404
+ ta.add_argument("--prompt", required=True,
405
+ help="the task prompt the agent will solve")
406
+ ta.add_argument("--criteria", default="",
407
+ help="success criterion for test-less tasks")
408
+ ta.add_argument("--attempts", type=int, default=4)
409
+ ta.add_argument("--tasks", default=None,
410
+ help="queue file (default: <memory>/tasks.json)")
411
+ tl = task_sub.add_parser("list", help="show queued tasks")
412
+ tl.add_argument("--tasks", default=None,
413
+ help="queue file (default: <memory>/tasks.json)")
414
+ ta.set_defaults(func=cmd_task)
415
+ tl.set_defaults(func=cmd_task)
416
+
348
417
  status = sub.add_parser("status", help="show learned policies, recipes, recent events")
349
418
  status.set_defaults(func=cmd_status)
350
419
 
@@ -390,7 +459,7 @@ def main(argv: list[str] | None = None) -> int:
390
459
  dis.add_argument("--category", default=None,
391
460
  help="KB category (default: cwd name of each episode)")
392
461
  dis.add_argument("--provider", default="openai",
393
- choices=["openai", "cursor", "local", "goose"])
462
+ choices=["openai", "cursor-agent", "cursor", "local", "goose"])
394
463
  dis.add_argument("--model", default=None)
395
464
  dis.add_argument("--limit", type=int, default=None)
396
465
  dis.add_argument("--dry-run", action="store_true",
@@ -302,6 +302,10 @@ class PolicySandbox:
302
302
  )
303
303
  except subprocess.TimeoutExpired:
304
304
  return PolicyRun(None, "policy execution timed out")
305
+ except subprocess.SubprocessError as exc:
306
+ # A failed spawn (e.g. "Exception occurred in preexec_fn" under a
307
+ # low hard rlimit) must not kill the whole cycle.
308
+ return PolicyRun(None, f"sandbox spawn failed: {exc}")
305
309
  finally:
306
310
  for p in (src_path, fr_path, harness_path):
307
311
  p.unlink(missing_ok=True)
@@ -339,6 +343,10 @@ class PolicySandbox:
339
343
  )
340
344
  except subprocess.TimeoutExpired:
341
345
  return None, [], 0, "policy rollout timed out"
346
+ except subprocess.SubprocessError as exc:
347
+ # See choose(): a failed spawn is an errored rollout, not a
348
+ # crashed cycle.
349
+ return None, [], 0, f"sandbox spawn failed: {exc}"
342
350
  finally:
343
351
  for p in (src_path, world_path, harness_path):
344
352
  p.unlink(missing_ok=True)
@@ -0,0 +1,92 @@
1
+ """Dreamer brain via the Cursor Agent CLI (headless, no HTTP endpoint).
2
+
3
+ Cursor ships no public OpenAI-compatible chat API, so the zero-proxy route
4
+ to "the dreamer thinks with Cursor" is the ``cursor-agent`` CLI: it runs a
5
+ headless agent turn authenticated by the user's Cursor login — no proxy, no
6
+ API key, no endpoint to configure. Output text mode returns the final
7
+ answer only, which is exactly what the curator contract needs.
8
+
9
+ Failure semantics (the dream ingest contract, 0.2.4+): every failure here
10
+ raises RuntimeError, which ``distill`` reports as ``llm error`` and
11
+ ``ingest_sessions`` treats as a transport error — the session is DEFERRED
12
+ (stays unseen), so a machine without the CLI simply pauses learning instead
13
+ of burning evidence.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ #: Hard ceiling for one headless agent turn. The curator's distill prompt is
25
+ #: small; a hang here must not stall the dream cycle.
26
+ DEFAULT_TIMEOUT = 240.0
27
+
28
+ _IS_WIN = sys.platform.startswith("win")
29
+
30
+
31
+ class CursorAgentClient:
32
+ """OpenAI-compatible-shaped client backed by ``cursor-agent -p``.
33
+
34
+ Accepts the same ``chat(messages, model=..., temperature=..., max_tokens=...)``
35
+ call shape as ``OpenAICompatibleClient`` so it drops in anywhere a client
36
+ is expected (loop, curator, judge). ``temperature``/``max_tokens`` have no
37
+ CLI equivalent and are ignored on purpose — the agent applies its own
38
+ defaults. ``model``: only passed through when set; unset means "whatever
39
+ the user's Cursor is configured for" (the no-pinning default).
40
+ """
41
+
42
+ def __init__(self, model: Optional[str] = None,
43
+ executable: str = "cursor-agent",
44
+ timeout: Optional[float] = None) -> None:
45
+ self.model = model or os.environ.get("ODR_LLM_MODEL") or None
46
+ self.executable = executable
47
+ self.timeout = float(
48
+ timeout or os.environ.get("ODR_CURSOR_AGENT_TIMEOUT") or DEFAULT_TIMEOUT)
49
+
50
+ def chat(self, messages: List[Dict[str, str]], model: Optional[str] = None,
51
+ temperature: float = 0.7, max_tokens: int = 1024) -> str:
52
+ """One headless agent turn over the flattened conversation."""
53
+ effective = model or self.model
54
+ exe = shutil.which(self.executable)
55
+ if exe is None:
56
+ raise RuntimeError(
57
+ "cursor-agent CLI not found on PATH — install the Cursor CLI "
58
+ "(cursor.com/docs/cli) or pick another --provider")
59
+ argv = [exe, "-p", "--force", "--output-format", "text"]
60
+ if effective:
61
+ argv += ["--model", effective]
62
+ argv.append(_flatten(messages))
63
+ if _IS_WIN and exe.lower().endswith((".cmd", ".bat")):
64
+ # Windows npm shims are .cmd batch files: CreateProcess cannot
65
+ # exec them directly, so ride cmd.exe /c (Python >= 3.12 quotes
66
+ # batch-file arguments safely — the Store 3.13 runtime qualifies).
67
+ argv = ["cmd.exe", "/c"] + argv
68
+ try:
69
+ proc = subprocess.run(
70
+ argv, capture_output=True, timeout=self.timeout,
71
+ text=True, encoding="utf-8", errors="replace")
72
+ except subprocess.TimeoutExpired as exc:
73
+ raise RuntimeError(
74
+ f"cursor-agent timed out after {self.timeout:g}s") from exc
75
+ if proc.returncode != 0:
76
+ raise RuntimeError(
77
+ f"cursor-agent rc={proc.returncode}: "
78
+ f"{(proc.stderr or proc.stdout or '')[:300]}")
79
+ text = (proc.stdout or "").strip()
80
+ if not text:
81
+ raise RuntimeError("cursor-agent returned no output")
82
+ return text
83
+
84
+
85
+ def _flatten(messages: List[Dict[str, str]]) -> str:
86
+ """system+user messages -> one prompt string (role-labelled)."""
87
+ parts = []
88
+ for m in messages:
89
+ role = str(m.get("role", "user"))
90
+ content = str(m.get("content", ""))
91
+ parts.append(f"[{role}]\n{content}" if role != "user" else content)
92
+ return "\n\n".join(parts)
@@ -2,7 +2,7 @@
2
2
 
3
3
  python -m open_dream_rsi dashboard # mock LLM, zero setup
4
4
  python -m open_dream_rsi dashboard --provider openai --tasks tasks.json
5
- python -m open_dream_rsi dashboard --provider cursor --model cursor-grok-4.5-high
5
+ python -m open_dream_rsi dashboard --provider cursor --model grok-4.7
6
6
 
7
7
  Opens a dark, dependency-free dashboard (stdlib http.server only) showing:
8
8
  KPIs (cycles / API calls / solved / dream iterations), a live event feed,
@@ -145,14 +145,20 @@ def _append_probe_tasks(memory_root: Path, probes: List[Dict[str, Any]]) -> int:
145
145
 
146
146
 
147
147
  def ingest_sessions(memory, sessions_db: "str | Path", *,
148
+ source: str = "hermes",
148
149
  limit: int = 25, client=None,
149
150
  category: Optional[str] = None) -> Dict[str, Any]:
150
151
  """Distil NEW (unseen) host sessions into staging lessons AND queue
151
152
  their source tasks as replay probes (the gate's counterparties).
152
153
 
153
- Returns {'new': n_sessions, 'added': n_lessons, 'probes': n_tasks}.
154
- Sessions already processed are skipped — transcripts stay in state.db,
155
- so skipping a cycle loses nothing.
154
+ ``source="hermes"`` reads a Hermes ``state.db``; ``source="cursor"``
155
+ reads the Cursor ``User`` dir (``state.vscdb``) directly — no MCP
156
+ server involved, so this also works where MCP is blocked by policy.
157
+
158
+ Returns {'new': n_sessions, 'added': n_lessons, 'probes': n_tasks,
159
+ 'deferred': n_deferred (endpoint failed; session left unseen)}.
160
+ Sessions already processed are skipped — transcripts stay in the
161
+ source store, so skipping a cycle loses nothing.
156
162
  """
157
163
  from open_dream_rsi.core.curator import (curate_lessons,
158
164
  evidence_snippets,
@@ -163,7 +169,15 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
163
169
  state = _load_state(root)
164
170
  seen = set(state.get("seen_sessions", []))
165
171
 
166
- eps = read_hermes_sessions(sessions_db, limit=limit + len(seen))
172
+ if source not in ("hermes", "cursor"):
173
+ raise ValueError(f"unknown session source {source!r} "
174
+ "(expected 'hermes' or 'cursor')")
175
+ if source == "cursor":
176
+ from open_dream_rsi.sessions import read_cursor_sessions
177
+ eps = read_cursor_sessions(user_dir=sessions_db,
178
+ limit=limit + len(seen))
179
+ else:
180
+ eps = read_hermes_sessions(sessions_db, limit=limit + len(seen))
167
181
  new = [e for e in eps if e.session_id not in seen][:limit]
168
182
  if not new:
169
183
  return {"new": 0, "added": 0}
@@ -175,6 +189,7 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
175
189
  # zero new so the caller stays in the cheap maintenance tier.
176
190
  return {"new": 0, "added": 0, "deferred": len(new)}
177
191
  added_total = 0
192
+ deferred = 0
178
193
  probes: List[Dict[str, Any]] = []
179
194
  for ep in new:
180
195
  seen.add(ep.session_id)
@@ -182,11 +197,23 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
182
197
  if not failures:
183
198
  continue
184
199
  cat = category or (Path(ep.cwd).name if ep.cwd else ep.source)
200
+ distilled, err = curator.distill(cat, ep.title or cat, failures,
201
+ memory.get_lessons(cat))
202
+ if err and err.startswith("llm error"):
203
+ # Transport failure (connect/HTTP/response shape): this session
204
+ # was never actually evaluated, so do NOT consume it — leave it
205
+ # unseen for the next cycle with a live endpoint. Consuming here
206
+ # would silently burn the evidence (a dead endpoint eats the
207
+ # backlog one session per fire, and the tier stays full).
208
+ seen.discard(ep.session_id)
209
+ deferred += 1
210
+ memory.log_event("lesson_deferred", source=ep.source,
211
+ session_id=ep.session_id,
212
+ reason=err[:300])
213
+ continue
185
214
  probe = _probe_task(ep, cat)
186
215
  if probe is not None:
187
216
  probes.append(probe)
188
- distilled, err = curator.distill(cat, ep.title or cat, failures,
189
- memory.get_lessons(cat))
190
217
  if err or not distilled:
191
218
  memory.log_event("lesson_rejected", source=ep.source,
192
219
  session_id=ep.session_id,
@@ -204,13 +231,15 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
204
231
  n_probes = _append_probe_tasks(root, probes)
205
232
  if n_probes:
206
233
  memory.log_event("probe_tasks_queued", count=n_probes)
207
- return {"new": len(new), "added": added_total, "probes": n_probes}
234
+ return {"new": len(new), "added": added_total, "probes": n_probes,
235
+ "deferred": deferred}
208
236
 
209
237
 
210
238
  # -- the seam ------------------------------------------------------------------
211
239
 
212
240
  def dream_once(memory_root: "str | Path", *,
213
241
  sessions_db: "str | Path | None" = None,
242
+ session_source: str = "hermes",
214
243
  skills_out: "str | Path | None" = None,
215
244
  client=None, tasks: Optional[List[Any]] = None,
216
245
  budget: int = 20, max_tokens: int = 4096,
@@ -231,7 +260,8 @@ def dream_once(memory_root: "str | Path", *,
231
260
  # 1. evidence (cheap unless new sessions carry failures)
232
261
  if sessions_db:
233
262
  report["ingest"] = ingest_sessions(
234
- memory, sessions_db, client=client, category=category)
263
+ memory, sessions_db, source=session_source,
264
+ client=client, category=category)
235
265
 
236
266
  # 2. decide the tier
237
267
  state = _load_state(root)
@@ -4,7 +4,8 @@ Open Dream-RSI does not require a specific provider — any endpoint that
4
4
  implements the OpenAI protocol (``POST {base_url}/chat/completions``) works:
5
5
 
6
6
  * OpenAI -> https://api.openai.com/v1
7
- * Cursor Models API -> https://api2.cursor.sh
7
+ * Cursor (via a local proxy over -> http://127.0.0.1:3000/v1
8
+ the Cursor login, e.g. cursor-openai-api)
8
9
  * local vLLM / Ollama / LM Studio -> http://127.0.0.1:8000/v1 etc.
9
10
 
10
11
  The client uses only the standard library (urllib), so the core package
@@ -63,10 +64,16 @@ ENDPOINT_PRESETS: Dict[str, Dict[str, str]] = {
63
64
  "api_key_env": "OPENAI_API_KEY",
64
65
  "default_model": "gpt-4o-mini",
65
66
  },
67
+ # Cursor ships no public OpenAI-compatible chat endpoint: a `crsr_` API
68
+ # key exposes the Cloud Agents API, and api2.cursor.sh is the IDE's
69
+ # internal (gRPC) backend — direct calls here can never work. The preset
70
+ # therefore targets a LOCAL OpenAI-compatible proxy over the Cursor login
71
+ # (e.g. `npx cursor-openai-api serve` on :3000) — see
72
+ # docs/integrations.md -> Cursor.
66
73
  "cursor": {
67
- "base_url": "https://api2.cursor.sh",
74
+ "base_url": "http://127.0.0.1:3000/v1",
68
75
  "api_key_env": "CURSOR_API_KEY",
69
- "default_model": "cursor-grok-4.5-high",
76
+ "default_model": "cursor-model",
70
77
  },
71
78
  "local": {
72
79
  "base_url": "http://127.0.0.1:8000/v1",
@@ -145,8 +152,8 @@ class LLMConfig:
145
152
  )
146
153
  if cfg.api_key is None and api_key_env:
147
154
  cfg.api_key = os.environ.get(api_key_env)
148
- if cfg.api_key is None and preset == "local":
149
- cfg.api_key = "local" # vLLM/Ollama accept any bearer token
155
+ if cfg.api_key is None and preset in ("local", "cursor"):
156
+ cfg.api_key = "local" # local servers and the Cursor login proxy accept any bearer token
150
157
  return cfg
151
158
 
152
159
  @classmethod
@@ -25,6 +25,7 @@ import json
25
25
  import random
26
26
  import time
27
27
  from dataclasses import dataclass, field
28
+ from pathlib import Path
28
29
  from typing import Any, Callable, Dict, List, Optional
29
30
 
30
31
  from open_dream_rsi.core.agent import ChatClient
@@ -113,6 +114,45 @@ class Task:
113
114
  criteria: str = ""
114
115
 
115
116
 
117
+ def append_task(tasks_file: "str | Path", task: Dict[str, Any]) -> Dict[str, Any]:
118
+ """Append one task dict to a tasks.json queue file (atomic, idempotent).
119
+
120
+ The single write path shared by the MCP tool, the CLI and any other
121
+ no-MCP queue feeder (hooks, scripts): validates the required fields,
122
+ normalises the test-less shape, refuses duplicate task_ids, and writes
123
+ through a temp file so a concurrent reader never sees a torn queue.
124
+ """
125
+ task = dict(task)
126
+ task.setdefault("max_attempts", 4)
127
+ if not task.get("tests"):
128
+ task["tests"] = []
129
+ for field_name in ("task_id", "category", "prompt"):
130
+ if not task.get(field_name):
131
+ raise ValueError(f"'{field_name}' is required")
132
+ if not task.get("tests") and not task.get("criteria"):
133
+ raise ValueError("either 'tests' (non-empty list of {call, expected}) "
134
+ "or 'criteria' (success description for the "
135
+ "completion judge) is required")
136
+ if task.get("tests") and (not isinstance(task["tests"], list)
137
+ or not task["tests"]):
138
+ raise ValueError("'tests' must be a non-empty list of {call, expected}")
139
+ path = Path(tasks_file)
140
+ existing: List[Dict[str, Any]] = []
141
+ if path.exists():
142
+ existing = json.loads(path.read_text(encoding="utf-8"))
143
+ if any(t.get("task_id") == task["task_id"] for t in existing):
144
+ return {"added": False,
145
+ "reason": f"task_id '{task['task_id']}' already queued"}
146
+ existing.append(task)
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ tmp = path.with_suffix(path.suffix + ".tmp")
149
+ tmp.write_text(json.dumps(existing, indent=2, ensure_ascii=False),
150
+ encoding="utf-8")
151
+ tmp.replace(path)
152
+ return {"added": True, "task_id": task["task_id"],
153
+ "tasks_total": len(existing), "tasks_file": str(path)}
154
+
155
+
116
156
  #: Minimum new replay steps required before re-asking the LLM for a policy
117
157
  #: (one full cycle's worth of evidence — keeps generation cost bounded).
118
158
  POLICY_REGROW_STEPS = 4
@@ -102,6 +102,8 @@ def tool_odr_lessons(args: Dict[str, Any]) -> Dict[str, Any]:
102
102
 
103
103
  def tool_odr_add_task(args: Dict[str, Any]) -> Dict[str, Any]:
104
104
  """Append a task to the task file so the next loop cycle picks it up."""
105
+ from open_dream_rsi.loop import append_task
106
+
105
107
  task = {
106
108
  "task_id": args.get("task_id"),
107
109
  "category": args.get("category"),
@@ -114,47 +116,31 @@ def tool_odr_add_task(args: Dict[str, Any]) -> Dict[str, Any]:
114
116
  for field_name in ("task_id", "category", "prompt"):
115
117
  if not task[field_name]:
116
118
  raise ValueError(f"'{field_name}' is required")
117
- if not task.get("tests") and not task.get("criteria"):
118
- raise ValueError("either 'tests' (non-empty list of {call, expected}) "
119
- "or 'criteria' (success description for the "
120
- "completion judge) is required")
121
- if task.get("tests") and (not isinstance(task["tests"], list)
122
- or not task["tests"]):
123
- raise ValueError("'tests' must be a non-empty list of {call, expected}")
124
- # Normalise the test-less shape: the key exists with None whenever the
125
- # caller passed no tests, so setdefault() would not fire and tasks.json
126
- # would carry "tests": null.
127
- if not task.get("tests"):
128
- task["tests"] = []
129
-
130
- path = _tasks_path(args)
131
- existing: List[Dict[str, Any]] = []
132
- if path.exists():
133
- existing = json.loads(path.read_text(encoding="utf-8"))
134
- if any(t.get("task_id") == task["task_id"] for t in existing):
135
- return {"added": False, "reason": f"task_id '{task['task_id']}' already queued"}
136
- existing.append(task)
137
- path.parent.mkdir(parents=True, exist_ok=True)
138
- tmp = path.with_suffix(path.suffix + ".tmp")
139
- tmp.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
140
- tmp.replace(path)
141
- return {"added": True, "task_id": task["task_id"], "tasks_total": len(existing),
142
- "tasks_file": str(path)}
119
+ return append_task(_tasks_path(args), task)
143
120
 
144
121
 
145
122
  def _default_provider() -> str:
146
123
  """Zero-configuration provider chain for harness-spawned servers.
147
124
 
148
- Precedence: ODR_LLM_PRESET > OPENAI_* env (proxy/direct) > goose's own
149
- model (desktop/CLI config on this machine) > local vLLM/Ollama default.
150
- A goose/Claude/Codex/Hermes-spawned MCP server often gets a scrubbed
151
- environment — the goose resolver reads config files instead of env, so
152
- the dreamer still borrows the host's brain without any setup.
125
+ Precedence: ODR_LLM_PRESET > OPENAI_* env (proxy/direct) > the Cursor
126
+ Agent CLI (headless brain over the user's Cursor login — no proxy, no
127
+ key) > goose's own model (desktop/CLI config on this machine) > local
128
+ vLLM/Ollama default. A goose/Claude/Codex/Hermes-spawned MCP server
129
+ often gets a scrubbed environment — the goose resolver reads config
130
+ files instead of env, so the dreamer still borrows the host's brain
131
+ without any setup.
153
132
  """
154
133
  if os.environ.get("ODR_LLM_PRESET"):
155
134
  return os.environ["ODR_LLM_PRESET"]
156
135
  if os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_KEY"):
157
136
  return "openai"
137
+ try:
138
+ import shutil
139
+
140
+ if shutil.which("cursor-agent"):
141
+ return "cursor-agent"
142
+ except Exception:
143
+ pass
158
144
  try:
159
145
  from open_dream_rsi.utils.goose import goose_available
160
146
 
@@ -57,21 +57,51 @@ def _memory_mb() -> int:
57
57
 
58
58
 
59
59
  def _make_preexec(timeout: float):
60
- """preexec_fn: RLIMIT_AS / RLIMIT_CPU / RLIMIT_NPROC for the child."""
60
+ """preexec_fn: RLIMIT_AS / RLIMIT_CPU / RLIMIT_NPROC for the child.
61
+
62
+ Best-effort by design: in restrictive environments (a hard rlimit below
63
+ the request — common for daemons and desktop sandboxes) ``setrlimit``
64
+ would raise and kill the spawn with ``Exception occurred in
65
+ preexec_fn``, so each limit is clamped to the hard ceiling and applied
66
+ only when it can be.
67
+ """
61
68
  import resource
62
69
 
63
70
  mem_bytes = _memory_mb() * 1024 * 1024
64
71
  cpu_soft = max(2, int(timeout) + 1)
65
72
 
73
+ def _apply(limit: int, soft: int, hard_extra: Optional[int]) -> bool:
74
+ """Set one rlimit, clamped to the environment's hard ceiling."""
75
+ try:
76
+ _, hard = resource.getrlimit(limit)
77
+ except (ValueError, OSError):
78
+ hard = resource.RLIM_INFINITY
79
+ if hard == 0:
80
+ # The environment forbids this resource entirely; applying
81
+ # soft=0 would insta-kill the child, so skip it.
82
+ return False
83
+ if hard != resource.RLIM_INFINITY:
84
+ soft = min(soft, hard)
85
+ if hard_extra is not None:
86
+ hard = (soft + hard_extra) if hard == resource.RLIM_INFINITY \
87
+ else min(hard, soft + hard_extra)
88
+ try:
89
+ resource.setrlimit(limit, (soft, hard))
90
+ except (ValueError, OSError):
91
+ return False
92
+ return True
93
+
66
94
  def _limits() -> None: # runs in the forked child only
67
- resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes))
68
- resource.setrlimit(resource.RLIMIT_CPU, (cpu_soft, cpu_soft + 1))
95
+ # Each limit is best-effort: degrade to fewer enforced limits
96
+ # rather than kill the spawn.
97
+ _apply(resource.RLIMIT_AS, mem_bytes, None)
98
+ _apply(resource.RLIMIT_CPU, cpu_soft, 1)
99
+ # RLIMIT_NPROC counts THREADS of this uid (shared with the parent),
100
+ # so the floor must sit ABOVE the uid's current task count or every
101
+ # fork fails with EAGAIN. Count threads via /proc, fall back to the
102
+ # static floor where /proc is absent.
103
+ existing = 0
69
104
  try:
70
- # RLIMIT_NPROC counts THREADS of this uid (shared with the
71
- # parent), so the floor must sit ABOVE the uid's current task
72
- # count or every fork fails with EAGAIN. Count threads via
73
- # /proc, fall back to the static floor where /proc is absent.
74
- existing = 0
75
105
  for entry in os.listdir("/proc"):
76
106
  if not entry.isdigit():
77
107
  continue
@@ -87,10 +117,7 @@ def _make_preexec(timeout: float):
87
117
  except OSError:
88
118
  existing = 0
89
119
  cap = max(MAX_PROCS, existing + 64)
90
- try:
91
- resource.setrlimit(resource.RLIMIT_NPROC, (cap, cap))
92
- except (ValueError, OSError):
93
- pass # platform without NPROC limits
120
+ _apply(resource.RLIMIT_NPROC, cap, None)
94
121
 
95
122
  return _limits
96
123
 
@@ -101,6 +101,12 @@ class CodeVerifier:
101
101
  except subprocess.TimeoutExpired:
102
102
  return {"ok": False, "error": "execution timed out",
103
103
  "defined": [], "stdout": ""}
104
+ except subprocess.SubprocessError as exc:
105
+ # Restrictive environments can fail the spawn itself (e.g.
106
+ # "Exception occurred in preexec_fn" under a low hard rlimit):
107
+ # report sandbox evidence instead of killing the whole cycle.
108
+ return {"ok": False, "error": f"sandbox spawn failed: {exc}",
109
+ "defined": [], "stdout": ""}
104
110
  finally:
105
111
  Path(path).unlink(missing_ok=True)
106
112
  line = proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else ""
@@ -129,6 +135,10 @@ class CodeVerifier:
129
135
  )
130
136
  except subprocess.TimeoutExpired:
131
137
  return ToolResult(False, 0.0, "execution timed out")
138
+ except subprocess.SubprocessError as exc:
139
+ # See smoke_run: a failed spawn is sandbox evidence, not a
140
+ # crashed cycle.
141
+ return ToolResult(False, 0.0, f"sandbox spawn failed: {exc}")
132
142
  finally:
133
143
  Path(path).unlink(missing_ok=True)
134
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-open-dream-rsi",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "Let your coding agent dream: strategies must survive counterfactual replay before they earn trust. An offline self-improvement loop over an evolving world model — no fine-tuning, the LLM stays frozen. Speaks MCP. Ships its Python server inside the package — the only requirement is a system python3 (3.10+) on PATH — and the plugin's bridge serves the dreamer's LLM calls from the model your harness is already configured with (no API keys, no env, no extra endpoints).",
5
5
  "type": "module",
6
6
  "license": "MIT",