dsh-open-dream-rsi 0.2.1 → 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/open_dream_rsi/cli.py +75 -6
- package/open_dream_rsi/cursor_agent.py +92 -0
- package/open_dream_rsi/dashboard.py +1 -1
- package/open_dream_rsi/dream.py +38 -8
- package/open_dream_rsi/llm.py +12 -5
- package/open_dream_rsi/loop.py +40 -0
- package/open_dream_rsi/mcp.py +17 -31
- package/package.json +1 -1
package/open_dream_rsi/cli.py
CHANGED
|
@@ -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=
|
|
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
|
|
333
|
-
"(
|
|
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",
|
|
@@ -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
|
|
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,
|
package/open_dream_rsi/dream.py
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
so
|
|
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
|
-
|
|
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,
|
|
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)
|
package/open_dream_rsi/llm.py
CHANGED
|
@@ -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
|
|
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": "
|
|
74
|
+
"base_url": "http://127.0.0.1:3000/v1",
|
|
68
75
|
"api_key_env": "CURSOR_API_KEY",
|
|
69
|
-
"default_model": "cursor-
|
|
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
|
|
149
|
-
cfg.api_key = "local" #
|
|
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
|
package/open_dream_rsi/loop.py
CHANGED
|
@@ -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
|
package/open_dream_rsi/mcp.py
CHANGED
|
@@ -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
|
-
|
|
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) >
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-open-dream-rsi",
|
|
3
|
-
"version": "0.2.
|
|
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",
|