dsh-open-dream-rsi 0.2.1 → 0.3.1
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 +100 -13
- package/open_dream_rsi/core/curator.py +7 -2
- package/open_dream_rsi/corrections.py +213 -0
- package/open_dream_rsi/cursor_agent.py +92 -0
- package/open_dream_rsi/dashboard.py +1 -1
- package/open_dream_rsi/dream.py +61 -11
- 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
|
|
@@ -144,31 +147,49 @@ def cmd_sessions(args: argparse.Namespace) -> int:
|
|
|
144
147
|
memory = DreamMemory(args.memory)
|
|
145
148
|
provider = getattr(args, "provider", "openai")
|
|
146
149
|
if args.dry_run:
|
|
150
|
+
from open_dream_rsi.corrections import user_corrections
|
|
147
151
|
for ep in eps[: args.limit or len(eps)]:
|
|
148
152
|
fails = episode_failures(ep)
|
|
153
|
+
corrs = user_corrections(ep)
|
|
149
154
|
print(f"[{ep.source}:{ep.session_id[:12]}] {ep.title[:60]} "
|
|
150
|
-
f"— {len(fails)} failure-shaped record(s)")
|
|
151
|
-
for f in fails[:3]:
|
|
155
|
+
f"— {len(fails)} failure-shaped, {len(corrs)} correction-shaped record(s)")
|
|
156
|
+
for f in (fails + corrs)[:3]:
|
|
152
157
|
print(" ", f["errors"][0][:100])
|
|
153
158
|
return 0
|
|
154
159
|
from open_dream_rsi.cli import _build_client
|
|
155
160
|
client = _build_client(provider, args.model)
|
|
156
161
|
curator = KnowledgeCurator(client)
|
|
162
|
+
from open_dream_rsi.corrections import (
|
|
163
|
+
dedupe_corrections,
|
|
164
|
+
distillable,
|
|
165
|
+
user_corrections,
|
|
166
|
+
)
|
|
157
167
|
total_added = 0
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
168
|
+
ep_evidence = [(ep, episode_failures(ep), user_corrections(ep))
|
|
169
|
+
for ep in eps]
|
|
170
|
+
all_corr = distillable(
|
|
171
|
+
dedupe_corrections([c for _, _, cs in ep_evidence for c in cs]))
|
|
172
|
+
kept = {(c.get("session_id"), c.get("msg_index")) for c in all_corr}
|
|
173
|
+
for ep, failures, corr_raw in ep_evidence:
|
|
174
|
+
corrections = [c for c in corr_raw
|
|
175
|
+
if (c.get("session_id"), c.get("msg_index")) in kept]
|
|
176
|
+
if corrections:
|
|
177
|
+
memory.log_event("user_correction_found", source=ep.source,
|
|
178
|
+
session_id=ep.session_id, count=len(corrections),
|
|
179
|
+
classes=[c.get("class", "") for c in corrections][:6])
|
|
180
|
+
evidence = failures + corrections
|
|
181
|
+
if not evidence:
|
|
161
182
|
continue
|
|
162
183
|
category = args.category or (
|
|
163
184
|
Path(ep.cwd).name if ep.cwd else ep.source)
|
|
164
185
|
distilled, err = curator.distill(category, ep.title or category,
|
|
165
|
-
|
|
186
|
+
evidence, memory.get_lessons(category))
|
|
166
187
|
if err or not distilled:
|
|
167
188
|
memory.log_event("lesson_rejected", source=ep.source,
|
|
168
189
|
session_id=ep.session_id, reason=(err or "empty")[:300])
|
|
169
190
|
continue
|
|
170
191
|
result = curate_lessons(memory.get_lessons(category), distilled,
|
|
171
|
-
evidence=evidence_snippets(
|
|
192
|
+
evidence=evidence_snippets(evidence))
|
|
172
193
|
memory.replace_lessons(category, result.entries)
|
|
173
194
|
memory.log_event("lessons_curated", source=ep.source,
|
|
174
195
|
session_id=ep.session_id, category=category,
|
|
@@ -181,6 +202,38 @@ def cmd_sessions(args: argparse.Namespace) -> int:
|
|
|
181
202
|
return 0
|
|
182
203
|
|
|
183
204
|
|
|
205
|
+
def cmd_task(args: argparse.Namespace) -> int:
|
|
206
|
+
"""Queue tasks without MCP: append_task writes the same tasks.json the
|
|
207
|
+
loop/dream cycle consumes, so hooks or a terminal can feed the loop on
|
|
208
|
+
hosts where MCP servers are unavailable (e.g. blocked by policy)."""
|
|
209
|
+
from open_dream_rsi.loop import append_task
|
|
210
|
+
|
|
211
|
+
tasks_file = (Path(args.tasks) if args.tasks
|
|
212
|
+
else Path(args.memory) / "tasks.json")
|
|
213
|
+
if args.task_cmd == "add":
|
|
214
|
+
task = {"task_id": args.id, "category": args.category,
|
|
215
|
+
"prompt": args.prompt, "tests": [],
|
|
216
|
+
"max_attempts": args.attempts}
|
|
217
|
+
if args.criteria:
|
|
218
|
+
task["criteria"] = args.criteria
|
|
219
|
+
try:
|
|
220
|
+
result = append_task(tasks_file, task)
|
|
221
|
+
except ValueError as exc:
|
|
222
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
223
|
+
return 2
|
|
224
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
225
|
+
return 0
|
|
226
|
+
tasks = (json.loads(tasks_file.read_text(encoding="utf-8"))
|
|
227
|
+
if tasks_file.exists() else [])
|
|
228
|
+
for t in tasks:
|
|
229
|
+
marker = ("criteria: " + t["criteria"][:60] if t.get("criteria")
|
|
230
|
+
else f"{len(t.get('tests') or [])} test(s)")
|
|
231
|
+
print(f"{t.get('task_id')}: [{t.get('category')}] "
|
|
232
|
+
f"attempts={t.get('max_attempts', 4)} {marker}")
|
|
233
|
+
print(f"({len(tasks)} task(s) in {tasks_file})")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
184
237
|
def cmd_dream(args: argparse.Namespace) -> int:
|
|
185
238
|
"""Automated cycle: evidence -> candidates -> promotion gate -> skills.
|
|
186
239
|
|
|
@@ -189,12 +242,20 @@ def cmd_dream(args: argparse.Namespace) -> int:
|
|
|
189
242
|
cost), so any trigger — a Hermes hook, the MCP tool, or this CLI —
|
|
190
243
|
can fire as often as it likes without overrunning the budget."""
|
|
191
244
|
from open_dream_rsi.dream import dream_once
|
|
245
|
+
from open_dream_rsi.sessions import default_cursor_user_dir
|
|
192
246
|
|
|
193
247
|
client = None
|
|
194
248
|
if not args.offline:
|
|
195
249
|
client = _build_client(args.provider, args.model)
|
|
250
|
+
if args.source == "cursor":
|
|
251
|
+
# the Cursor store is a User DIR (state.vscdb inside), not a db file
|
|
252
|
+
sessions_db = args.sessions or args.cursor_user_dir \
|
|
253
|
+
or str(default_cursor_user_dir())
|
|
254
|
+
else:
|
|
255
|
+
sessions_db = args.sessions
|
|
196
256
|
report = dream_once(
|
|
197
|
-
args.memory, sessions_db=
|
|
257
|
+
args.memory, sessions_db=sessions_db, session_source=args.source,
|
|
258
|
+
skills_out=args.skills_out,
|
|
198
259
|
client=client, budget=args.budget, max_tokens=args.max_tokens,
|
|
199
260
|
category=args.category)
|
|
200
261
|
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
@@ -298,7 +359,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
298
359
|
loop = sub.add_parser("loop", help="run the self-improvement cycle")
|
|
299
360
|
loop.add_argument("--tasks", required=True, help="JSON file: list of Task dicts")
|
|
300
361
|
loop.add_argument("--provider", default="openai",
|
|
301
|
-
choices=["openai", "cursor", "local", "goose"],
|
|
362
|
+
choices=["openai", "cursor-agent", "cursor", "local", "goose"],
|
|
302
363
|
help="LLM endpoint; 'goose' borrows goose's own "
|
|
303
364
|
"provider/model/key from ~/.config/goose")
|
|
304
365
|
loop.add_argument("--model", default=None)
|
|
@@ -329,12 +390,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
329
390
|
"maintenance, skills publish; safe to fire from any trigger "
|
|
330
391
|
"(cadence is decided internally, not by the caller)")
|
|
331
392
|
dr.add_argument("--sessions", default=None,
|
|
332
|
-
help="host
|
|
333
|
-
"(
|
|
393
|
+
help="host session store to import evidence from "
|
|
394
|
+
"(hermes: state.db path; cursor: the User dir)")
|
|
395
|
+
dr.add_argument("--source", default="hermes", choices=["hermes", "cursor"],
|
|
396
|
+
help="kind of the --sessions store (default hermes; "
|
|
397
|
+
"cursor reads state.vscdb directly, no MCP needed)")
|
|
398
|
+
dr.add_argument("--cursor-user-dir", default=None,
|
|
399
|
+
help="Cursor User dir for --source cursor (default: the "
|
|
400
|
+
"platform location, e.g. %%APPDATA%%\\Cursor\\User)")
|
|
334
401
|
dr.add_argument("--skills-out", default=None,
|
|
335
402
|
help="render ACTIVE lessons here as SKILL.md files")
|
|
336
403
|
dr.add_argument("--provider", default="openai",
|
|
337
|
-
choices=["openai", "cursor", "local", "goose"])
|
|
404
|
+
choices=["openai", "cursor-agent", "cursor", "local", "goose"])
|
|
338
405
|
dr.add_argument("--model", default=None)
|
|
339
406
|
dr.add_argument("--category", default=None,
|
|
340
407
|
help="force one KB category (default: per-episode cwd)")
|
|
@@ -345,6 +412,26 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
345
412
|
help="no LLM: lifecycle maintenance only")
|
|
346
413
|
dr.set_defaults(func=cmd_dream)
|
|
347
414
|
|
|
415
|
+
task = sub.add_parser(
|
|
416
|
+
"task", help="queue tasks without MCP (the file the loop consumes)")
|
|
417
|
+
task_sub = task.add_subparsers(dest="task_cmd", required=True)
|
|
418
|
+
ta = task_sub.add_parser("add", help="append one task to the queue file")
|
|
419
|
+
ta.add_argument("--id", required=True, help="unique task id")
|
|
420
|
+
ta.add_argument("--category", required=True,
|
|
421
|
+
help="KB category (usually the project name)")
|
|
422
|
+
ta.add_argument("--prompt", required=True,
|
|
423
|
+
help="the task prompt the agent will solve")
|
|
424
|
+
ta.add_argument("--criteria", default="",
|
|
425
|
+
help="success criterion for test-less tasks")
|
|
426
|
+
ta.add_argument("--attempts", type=int, default=4)
|
|
427
|
+
ta.add_argument("--tasks", default=None,
|
|
428
|
+
help="queue file (default: <memory>/tasks.json)")
|
|
429
|
+
tl = task_sub.add_parser("list", help="show queued tasks")
|
|
430
|
+
tl.add_argument("--tasks", default=None,
|
|
431
|
+
help="queue file (default: <memory>/tasks.json)")
|
|
432
|
+
ta.set_defaults(func=cmd_task)
|
|
433
|
+
tl.set_defaults(func=cmd_task)
|
|
434
|
+
|
|
348
435
|
status = sub.add_parser("status", help="show learned policies, recipes, recent events")
|
|
349
436
|
status.set_defaults(func=cmd_status)
|
|
350
437
|
|
|
@@ -390,7 +477,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
390
477
|
dis.add_argument("--category", default=None,
|
|
391
478
|
help="KB category (default: cwd name of each episode)")
|
|
392
479
|
dis.add_argument("--provider", default="openai",
|
|
393
|
-
choices=["openai", "cursor", "local", "goose"])
|
|
480
|
+
choices=["openai", "cursor-agent", "cursor", "local", "goose"])
|
|
394
481
|
dis.add_argument("--model", default=None)
|
|
395
482
|
dis.add_argument("--limit", type=int, default=None)
|
|
396
483
|
dis.add_argument("--dry-run", action="store_true",
|
|
@@ -397,11 +397,16 @@ class KnowledgeCurator:
|
|
|
397
397
|
known = "\n".join(f" - [{l.get('trigger', '')}] {l.get('text', '')}"
|
|
398
398
|
for l in existing[-MAX_LESSONS_PER_CATEGORY:]) or "(empty KB)"
|
|
399
399
|
feedback = ""
|
|
400
|
+
has_corr = any("user said:" in e for e in evidence)
|
|
400
401
|
for attempt in range(1 + self.max_repair):
|
|
401
402
|
user = (f"Category [{category}]: {task_prompt}\n\n"
|
|
402
403
|
f"Lessons already in the knowledge base (do not restate):\n{known}\n\n"
|
|
403
|
-
f"Failed attempts this cycle:\n" + "\n".join(evidence) +
|
|
404
|
-
|
|
404
|
+
f"Failed attempts this cycle:\n" + "\n".join(evidence) +
|
|
405
|
+
("\n\nNote: some records are user corrections (action starts with "
|
|
406
|
+
"'user_correction'): the user's wording is evidence worth testing, "
|
|
407
|
+
"not verified truth; distil only the reusable, scoped rule."
|
|
408
|
+
if has_corr else "") +
|
|
409
|
+
f"\n\nPrevious rejection reason: {feedback or '(none)'}\n\n"
|
|
405
410
|
f"Return the JSON array of at most {self.max_lessons} lessons.")
|
|
406
411
|
kwargs: Dict[str, Any] = {"temperature": 0.5} # distillation, not creativity
|
|
407
412
|
kwargs["max_tokens"] = self.max_tokens
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""User-correction extraction (issue #6): natural-language evidence.
|
|
2
|
+
|
|
3
|
+
Host sessions contain corrections the user states in prose ("nie tak,
|
|
4
|
+
użyj istniejącego klienta", "don't create another client") — invisible to
|
|
5
|
+
:func:`open_dream_rsi.sessions.episode_failures`, which scans tool output
|
|
6
|
+
for error markers. This module turns such turns into evidence records
|
|
7
|
+
shaped like failure records (``action`` / ``score`` / ``errors``) so the
|
|
8
|
+
existing curator and replay-gate flow consumes them without a second
|
|
9
|
+
pipeline, while keeping the ``user_correction`` evidence kind and
|
|
10
|
+
provenance recoverable (README § Verdicts must be replayable).
|
|
11
|
+
|
|
12
|
+
Trust model is unchanged: a user correction is *evidence worth testing*,
|
|
13
|
+
not verified truth. Nothing here can make a lesson ACTIVE — candidates
|
|
14
|
+
enter staging like every other lesson and face the same paired-replay
|
|
15
|
+
gate. Extraction is deterministic and deliberately conservative: only
|
|
16
|
+
obvious, well-scoped candidates are kept; ambiguous negatives, ordinary
|
|
17
|
+
follow-ups and task expansions stay unclassified.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
__all__ = ["classify_user_message", "user_corrections", "dedupe_corrections",
|
|
24
|
+
"prioritize_corrections", "distillable"]
|
|
25
|
+
|
|
26
|
+
#: Turns that reject a result or approach ("Correction" / "Approach
|
|
27
|
+
#: redirect" in issue #6). Kept tight on purpose — a broad "nie|not" net
|
|
28
|
+
#: would swallow ordinary follow-ups (the measured v3 lesson: trust is
|
|
29
|
+
#: earned per class, never by breadth).
|
|
30
|
+
_CORRECTION_HINTS = re.compile(
|
|
31
|
+
r"\b(?:nie\s+tak|nie\s+o\s+to|nie\s+chodzi\s+o|chodzi\s+mi\s+o|"
|
|
32
|
+
r"źle|błędnie|błędne|błędn|złe\s+założen|przecież|zamiast|"
|
|
33
|
+
r"nadpis|nie\s+wolno|nie\s+rób|nie\s+używaj|nie\s+twórz|"
|
|
34
|
+
r"weź\s+pod\s+uwagę|uwzględnij|użyj\s+istniejąc|skorzystaj\s+z\s+istniejąc|"
|
|
35
|
+
r"jak\s+(?:napisałem|mówiłem|prosiłem)|już\s+(?:pisałem|mówiłem|prosiłem)|"
|
|
36
|
+
r"znowu\s+(?:to\s+samo|piszesz|robisz)|"
|
|
37
|
+
r"\bwrong\b|\bincorrectly?\b|that'?s\s+not|that\s+is\s+not|instead\s+of|"
|
|
38
|
+
r"don't\s+(?:do|create|use|touch|add|overwrite)|do\s+not\s+(?:do|create|use|touch|add|overwrite)|"
|
|
39
|
+
r"reuse\s+(?:the\s+)?(?:existing|shared)|as\s+i\s+(?:said|wrote|told|asked)|"
|
|
40
|
+
r"we\s+already\s+(?:tried|did|fixed)|\bbreaks\b|\bbroke\b|stop\s+(?:doing|touching|creating)"
|
|
41
|
+
r")", re.IGNORECASE)
|
|
42
|
+
|
|
43
|
+
#: Stated conventions ("Preference" in issue #6). Legitimate scoped-rule
|
|
44
|
+
#: evidence, but never framed as a failure lesson by the caller.
|
|
45
|
+
_PREFERENCE_HINTS = re.compile(
|
|
46
|
+
r"\b(?:preferuję|wolę|zawsze\s+(?:używaj|pisz|rób|dziel)|"
|
|
47
|
+
r"nigdy\s+(?:nie\s+)?(?:używaj|pisz|rób)|trzymaj\s+się\s+konwencji|"
|
|
48
|
+
r"\bprefer\b|\bi\s+prefer\b|\bi\s+usually\b|always\s+(?:use|write|split)|"
|
|
49
|
+
r"never\s+(?:use|do|commit)|convention\s+is\b)", re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
#: Task expansion / new requirement ("New requirement or clarification").
|
|
52
|
+
#: Extracted for completeness (the classes must be distinguishable) but
|
|
53
|
+
#: NOT distillation-ready by default: it is not evidence of a mistake.
|
|
54
|
+
_REQUIREMENT_HINTS = re.compile(
|
|
55
|
+
r"\b(?:dodaj\s+jeszcze|dołóż|także\s+|poza\s+tym|oprócz\s+tego|"
|
|
56
|
+
r"a\s+co\s+z\s+|możesz\s+też|przy\s+okazji|"
|
|
57
|
+
r"what\s+about\b|also\s+add|in\s+addition|one\s+more\s+thing|"
|
|
58
|
+
r"while\s+you'?re\s+at\s+it)", re.IGNORECASE)
|
|
59
|
+
|
|
60
|
+
#: Cheap ambiguity guards: short reactions, thanks, questions and bare
|
|
61
|
+
#: acknowledgements are never promoted (issue §1: reject or leave
|
|
62
|
+
#: unclassified rather than promote).
|
|
63
|
+
_AMBIGUITY = re.compile(
|
|
64
|
+
r"^\s*(?:no|no,|nope|ok|okej|okay|super|spoko|git|dobrze|dziękuję|dzieki|thanks|"
|
|
65
|
+
r"thank\s+you|thx|why|czemu|dlaczego|ale\b)[.!?,\s]|(\?)")
|
|
66
|
+
|
|
67
|
+
#: Assistant replies that suggest the session recovered after the
|
|
68
|
+
#: correction ("subsequent outcome supports the correction").
|
|
69
|
+
_OUTCOME_MARKERS = ("działa", "dziala", "zrobione", "gotowe", "naprawione",
|
|
70
|
+
"passes", "passed", "fixed", "works now", "solved")
|
|
71
|
+
|
|
72
|
+
_MIN_TEXT = 25
|
|
73
|
+
_MAX_RECORDS = 6
|
|
74
|
+
_MAX_TEXT = 220
|
|
75
|
+
|
|
76
|
+
_STOPWORDS = {
|
|
77
|
+
"this", "that", "with", "have", "jest", "nie", "jak", "dla", "ale",
|
|
78
|
+
"się", "sie", "tym", "tego", "który", "ktore", "ktora", "jestem",
|
|
79
|
+
"made", "make", "want", "need", "please", "prosze", "proszę", "the",
|
|
80
|
+
"and", "you", "your", "have", "that", "this", "with", "dont", "don't",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def classify_user_message(text: str) -> Optional[str]:
|
|
85
|
+
"""Classify one user turn: ``correction`` | ``approach_redirect`` |
|
|
86
|
+
``preference`` | ``new_requirement`` | ``None`` (not a candidate).
|
|
87
|
+
|
|
88
|
+
``correction`` wins over ``approach_redirect`` when both fire — a
|
|
89
|
+
rejection with a proposed alternative is recorded as the correction
|
|
90
|
+
it is; the redirect nuance lives in the text, not the label.
|
|
91
|
+
"""
|
|
92
|
+
t = (text or "").strip()
|
|
93
|
+
if len(t) < _MIN_TEXT or _AMBIGUITY.search(t[:24] or t):
|
|
94
|
+
return None
|
|
95
|
+
corr = _CORRECTION_HINTS.search(t)
|
|
96
|
+
pref = _PREFERENCE_HINTS.search(t)
|
|
97
|
+
# Earliest explicit signal wins: "Preferuję małe commity zamiast jednego
|
|
98
|
+
# dużego" states a preference — the comparative "zamiast" is not a
|
|
99
|
+
# rejection — while "Nie twórz X, zamiast tego użyj Y" leads with the
|
|
100
|
+
# rejection. Ties resolve to correction (the class the gate and priority
|
|
101
|
+
# ranking are calibrated for).
|
|
102
|
+
if pref and (not corr or pref.start() < corr.start()):
|
|
103
|
+
return "preference"
|
|
104
|
+
if corr:
|
|
105
|
+
return "correction"
|
|
106
|
+
if _REQUIREMENT_HINTS.search(t):
|
|
107
|
+
return "new_requirement"
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def user_corrections(ep: Any, max_items: int = _MAX_RECORDS) -> List[Dict[str, Any]]:
|
|
112
|
+
"""Extract user-correction evidence records from one episode.
|
|
113
|
+
|
|
114
|
+
Each record pairs the correction with the assistant turn it targets
|
|
115
|
+
(nearest preceding assistant message, tool rows skipped) and carries
|
|
116
|
+
provenance (source host, session id, workspace, message index) plus
|
|
117
|
+
``outcome_supported`` — whether the next assistant reply suggests the
|
|
118
|
+
session recovered after the correction. Message text arrives already
|
|
119
|
+
secret-redacted from the readers; nothing is added back.
|
|
120
|
+
"""
|
|
121
|
+
records: List[Dict[str, Any]] = []
|
|
122
|
+
msgs = list(getattr(ep, "messages", []))
|
|
123
|
+
for i, m in enumerate(msgs):
|
|
124
|
+
if getattr(m, "role", "") != "user":
|
|
125
|
+
continue
|
|
126
|
+
cls = classify_user_message(m.text or "")
|
|
127
|
+
if cls is None:
|
|
128
|
+
continue
|
|
129
|
+
assistant_text = ""
|
|
130
|
+
for j in range(i - 1, -1, -1):
|
|
131
|
+
if msgs[j].role == "assistant":
|
|
132
|
+
assistant_text = msgs[j].text or ""
|
|
133
|
+
break
|
|
134
|
+
outcome = False
|
|
135
|
+
for j in range(i + 1, len(msgs)):
|
|
136
|
+
if msgs[j].role == "assistant":
|
|
137
|
+
low = (msgs[j].text or "").lower()
|
|
138
|
+
outcome = any(k in low for k in _OUTCOME_MARKERS)
|
|
139
|
+
break
|
|
140
|
+
records.append({
|
|
141
|
+
"action": f"user_correction:{getattr(ep, 'source', '')}:"
|
|
142
|
+
f"{str(getattr(ep, 'session_id', ''))[:12]}#{i}",
|
|
143
|
+
"score": 0.0,
|
|
144
|
+
"errors": [
|
|
145
|
+
f"user said: {(m.text or '')[:_MAX_TEXT]}",
|
|
146
|
+
f"assistant had: {assistant_text[:_MAX_TEXT]}",
|
|
147
|
+
f"outcome: {'confirmed' if outcome else 'unverified'}",
|
|
148
|
+
],
|
|
149
|
+
"kind": "user_correction",
|
|
150
|
+
"class": cls,
|
|
151
|
+
"source": getattr(ep, "source", ""),
|
|
152
|
+
"session_id": getattr(ep, "session_id", ""),
|
|
153
|
+
"cwd": getattr(ep, "cwd", ""),
|
|
154
|
+
"msg_index": i,
|
|
155
|
+
"outcome_supported": outcome,
|
|
156
|
+
"repeats": 1,
|
|
157
|
+
"occurrences": 1,
|
|
158
|
+
})
|
|
159
|
+
return records[-max_items:]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _content_words(text: str) -> frozenset:
|
|
163
|
+
words = re.findall(r"[a-ząćęłńóśźż]{4,}", (text or "").lower())
|
|
164
|
+
return frozenset(w for w in words if w not in _STOPWORDS)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dedupe_corrections(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
168
|
+
"""Collapse near-identical corrections before any paid curator call.
|
|
169
|
+
|
|
170
|
+
Two records merge when their content words overlap (Jaccard >= 0.6).
|
|
171
|
+
Repeats inside ONE episode do not strengthen the evidence: ``repeats``
|
|
172
|
+
counts distinct episodes behind the observation, ``occurrences`` the
|
|
173
|
+
raw message count (issue §3).
|
|
174
|
+
"""
|
|
175
|
+
kept: List[Dict[str, Any]] = []
|
|
176
|
+
sigs: List[frozenset] = []
|
|
177
|
+
for rec in records:
|
|
178
|
+
sig = _content_words(" ".join(rec.get("errors", [])))
|
|
179
|
+
merged = False
|
|
180
|
+
for k, (old, old_sig) in enumerate(zip(kept, sigs)):
|
|
181
|
+
if old_sig and sig and (len(sig & old_sig) / len(sig | old_sig)) >= 0.6:
|
|
182
|
+
old["occurrences"] += 1
|
|
183
|
+
if old.get("session_id") != rec.get("session_id"):
|
|
184
|
+
old["repeats"] += 1
|
|
185
|
+
if rec.get("outcome_supported") and not old.get("outcome_supported"):
|
|
186
|
+
old["outcome_supported"] = True
|
|
187
|
+
old["errors"] = list(old["errors"])[:2] + ["outcome: confirmed"]
|
|
188
|
+
merged = True
|
|
189
|
+
break
|
|
190
|
+
if not merged:
|
|
191
|
+
kept.append(rec)
|
|
192
|
+
sigs.append(sig)
|
|
193
|
+
return kept
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def prioritize_corrections(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
197
|
+
"""Highest-priority first (issue §3): confirmed outcome > repeated
|
|
198
|
+
across episodes > bare correction > preference. Stable sort."""
|
|
199
|
+
def rank(r: Dict[str, Any]) -> tuple:
|
|
200
|
+
return (
|
|
201
|
+
3 if r.get("outcome_supported") else 0,
|
|
202
|
+
2 if r.get("repeats", 1) >= 2 else 0,
|
|
203
|
+
1 if r.get("class") == "correction" else 0,
|
|
204
|
+
)
|
|
205
|
+
return sorted(records, key=rank, reverse=True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def distillable(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
209
|
+
"""Records eligible for the existing curator flow: corrections,
|
|
210
|
+
redirects and preferences — never new requirements or unclassified
|
|
211
|
+
turns (issue §1/§3: a task expansion is not evidence of a mistake)."""
|
|
212
|
+
return [r for r in records
|
|
213
|
+
if r.get("class") in ("correction", "approach_redirect", "preference")]
|
|
@@ -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,25 +145,44 @@ 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,
|
|
159
165
|
KnowledgeCurator)
|
|
160
166
|
from open_dream_rsi.sessions import episode_failures, read_hermes_sessions
|
|
167
|
+
from open_dream_rsi.corrections import (
|
|
168
|
+
dedupe_corrections,
|
|
169
|
+
distillable,
|
|
170
|
+
user_corrections,
|
|
171
|
+
)
|
|
161
172
|
|
|
162
173
|
root = Path(memory.root)
|
|
163
174
|
state = _load_state(root)
|
|
164
175
|
seen = set(state.get("seen_sessions", []))
|
|
165
176
|
|
|
166
|
-
|
|
177
|
+
if source not in ("hermes", "cursor"):
|
|
178
|
+
raise ValueError(f"unknown session source {source!r} "
|
|
179
|
+
"(expected 'hermes' or 'cursor')")
|
|
180
|
+
if source == "cursor":
|
|
181
|
+
from open_dream_rsi.sessions import read_cursor_sessions
|
|
182
|
+
eps = read_cursor_sessions(user_dir=sessions_db,
|
|
183
|
+
limit=limit + len(seen))
|
|
184
|
+
else:
|
|
185
|
+
eps = read_hermes_sessions(sessions_db, limit=limit + len(seen))
|
|
167
186
|
new = [e for e in eps if e.session_id not in seen][:limit]
|
|
168
187
|
if not new:
|
|
169
188
|
return {"new": 0, "added": 0}
|
|
@@ -175,18 +194,46 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
|
|
|
175
194
|
# zero new so the caller stays in the cheap maintenance tier.
|
|
176
195
|
return {"new": 0, "added": 0, "deferred": len(new)}
|
|
177
196
|
added_total = 0
|
|
197
|
+
deferred = 0
|
|
178
198
|
probes: List[Dict[str, Any]] = []
|
|
179
|
-
|
|
199
|
+
# Issue #6: user corrections are a second evidence class. Collect per
|
|
200
|
+
# episode, dedupe deterministically ACROSS the batch before any paid
|
|
201
|
+
# curator call, then distil failures + surviving corrections together
|
|
202
|
+
# — one pipeline, one gate, no second learning path.
|
|
203
|
+
ep_evidence = [(ep, episode_failures(ep), user_corrections(ep))
|
|
204
|
+
for ep in new]
|
|
205
|
+
all_corr = distillable(
|
|
206
|
+
dedupe_corrections([c for _, _, cs in ep_evidence for c in cs]))
|
|
207
|
+
kept = {(c.get("session_id"), c.get("msg_index")) for c in all_corr}
|
|
208
|
+
for ep, failures, corr_raw in ep_evidence:
|
|
180
209
|
seen.add(ep.session_id)
|
|
181
|
-
|
|
182
|
-
|
|
210
|
+
corrections = [c for c in corr_raw
|
|
211
|
+
if (c.get("session_id"), c.get("msg_index")) in kept]
|
|
212
|
+
if corrections:
|
|
213
|
+
memory.log_event("user_correction_found", source=ep.source,
|
|
214
|
+
session_id=ep.session_id, count=len(corrections),
|
|
215
|
+
classes=[c.get("class", "") for c in corrections][:6])
|
|
216
|
+
evidence = failures + corrections
|
|
217
|
+
if not evidence:
|
|
183
218
|
continue
|
|
184
219
|
cat = category or (Path(ep.cwd).name if ep.cwd else ep.source)
|
|
220
|
+
distilled, err = curator.distill(cat, ep.title or cat, evidence,
|
|
221
|
+
memory.get_lessons(cat))
|
|
222
|
+
if err and err.startswith("llm error"):
|
|
223
|
+
# Transport failure (connect/HTTP/response shape): this session
|
|
224
|
+
# was never actually evaluated, so do NOT consume it — leave it
|
|
225
|
+
# unseen for the next cycle with a live endpoint. Consuming here
|
|
226
|
+
# would silently burn the evidence (a dead endpoint eats the
|
|
227
|
+
# backlog one session per fire, and the tier stays full).
|
|
228
|
+
seen.discard(ep.session_id)
|
|
229
|
+
deferred += 1
|
|
230
|
+
memory.log_event("lesson_deferred", source=ep.source,
|
|
231
|
+
session_id=ep.session_id,
|
|
232
|
+
reason=err[:300])
|
|
233
|
+
continue
|
|
185
234
|
probe = _probe_task(ep, cat)
|
|
186
235
|
if probe is not None:
|
|
187
236
|
probes.append(probe)
|
|
188
|
-
distilled, err = curator.distill(cat, ep.title or cat, failures,
|
|
189
|
-
memory.get_lessons(cat))
|
|
190
237
|
if err or not distilled:
|
|
191
238
|
memory.log_event("lesson_rejected", source=ep.source,
|
|
192
239
|
session_id=ep.session_id,
|
|
@@ -204,13 +251,15 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
|
|
|
204
251
|
n_probes = _append_probe_tasks(root, probes)
|
|
205
252
|
if n_probes:
|
|
206
253
|
memory.log_event("probe_tasks_queued", count=n_probes)
|
|
207
|
-
return {"new": len(new), "added": added_total, "probes": n_probes
|
|
254
|
+
return {"new": len(new), "added": added_total, "probes": n_probes,
|
|
255
|
+
"deferred": deferred}
|
|
208
256
|
|
|
209
257
|
|
|
210
258
|
# -- the seam ------------------------------------------------------------------
|
|
211
259
|
|
|
212
260
|
def dream_once(memory_root: "str | Path", *,
|
|
213
261
|
sessions_db: "str | Path | None" = None,
|
|
262
|
+
session_source: str = "hermes",
|
|
214
263
|
skills_out: "str | Path | None" = None,
|
|
215
264
|
client=None, tasks: Optional[List[Any]] = None,
|
|
216
265
|
budget: int = 20, max_tokens: int = 4096,
|
|
@@ -231,7 +280,8 @@ def dream_once(memory_root: "str | Path", *,
|
|
|
231
280
|
# 1. evidence (cheap unless new sessions carry failures)
|
|
232
281
|
if sessions_db:
|
|
233
282
|
report["ingest"] = ingest_sessions(
|
|
234
|
-
memory, sessions_db,
|
|
283
|
+
memory, sessions_db, source=session_source,
|
|
284
|
+
client=client, category=category)
|
|
235
285
|
|
|
236
286
|
# 2. decide the tier
|
|
237
287
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|