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.
@@ -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
- for ep in eps:
159
- failures = episode_failures(ep)
160
- if not failures:
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
- failures, memory.get_lessons(category))
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(failures))
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=args.sessions, skills_out=args.skills_out,
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 state.db to import evidence from "
333
- "(e.g. ~/.hermes/state.db)")
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) + "\n\n"
404
- f"Previous rejection reason: {feedback or '(none)'}\n\n"
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 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,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
- 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,
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
- eps = read_hermes_sessions(sessions_db, limit=limit + len(seen))
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
- for ep in new:
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
- failures = episode_failures(ep)
182
- if not failures:
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, client=client, category=category)
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)
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-open-dream-rsi",
3
- "version": "0.2.1",
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",