dsh-open-dream-rsi 0.2.3 → 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.
@@ -147,31 +147,49 @@ def cmd_sessions(args: argparse.Namespace) -> int:
147
147
  memory = DreamMemory(args.memory)
148
148
  provider = getattr(args, "provider", "openai")
149
149
  if args.dry_run:
150
+ from open_dream_rsi.corrections import user_corrections
150
151
  for ep in eps[: args.limit or len(eps)]:
151
152
  fails = episode_failures(ep)
153
+ corrs = user_corrections(ep)
152
154
  print(f"[{ep.source}:{ep.session_id[:12]}] {ep.title[:60]} "
153
- f"— {len(fails)} failure-shaped record(s)")
154
- for f in fails[:3]:
155
+ f"— {len(fails)} failure-shaped, {len(corrs)} correction-shaped record(s)")
156
+ for f in (fails + corrs)[:3]:
155
157
  print(" ", f["errors"][0][:100])
156
158
  return 0
157
159
  from open_dream_rsi.cli import _build_client
158
160
  client = _build_client(provider, args.model)
159
161
  curator = KnowledgeCurator(client)
162
+ from open_dream_rsi.corrections import (
163
+ dedupe_corrections,
164
+ distillable,
165
+ user_corrections,
166
+ )
160
167
  total_added = 0
161
- for ep in eps:
162
- failures = episode_failures(ep)
163
- 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:
164
182
  continue
165
183
  category = args.category or (
166
184
  Path(ep.cwd).name if ep.cwd else ep.source)
167
185
  distilled, err = curator.distill(category, ep.title or category,
168
- failures, memory.get_lessons(category))
186
+ evidence, memory.get_lessons(category))
169
187
  if err or not distilled:
170
188
  memory.log_event("lesson_rejected", source=ep.source,
171
189
  session_id=ep.session_id, reason=(err or "empty")[:300])
172
190
  continue
173
191
  result = curate_lessons(memory.get_lessons(category), distilled,
174
- evidence=evidence_snippets(failures))
192
+ evidence=evidence_snippets(evidence))
175
193
  memory.replace_lessons(category, result.entries)
176
194
  memory.log_event("lessons_curated", source=ep.source,
177
195
  session_id=ep.session_id, category=category,
@@ -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")]
@@ -164,6 +164,11 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
164
164
  evidence_snippets,
165
165
  KnowledgeCurator)
166
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
+ )
167
172
 
168
173
  root = Path(memory.root)
169
174
  state = _load_state(root)
@@ -191,13 +196,28 @@ def ingest_sessions(memory, sessions_db: "str | Path", *,
191
196
  added_total = 0
192
197
  deferred = 0
193
198
  probes: List[Dict[str, Any]] = []
194
- 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:
195
209
  seen.add(ep.session_id)
196
- failures = episode_failures(ep)
197
- 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:
198
218
  continue
199
219
  cat = category or (Path(ep.cwd).name if ep.cwd else ep.source)
200
- distilled, err = curator.distill(cat, ep.title or cat, failures,
220
+ distilled, err = curator.distill(cat, ep.title or cat, evidence,
201
221
  memory.get_lessons(cat))
202
222
  if err and err.startswith("llm error"):
203
223
  # Transport failure (connect/HTTP/response shape): this session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-open-dream-rsi",
3
- "version": "0.2.3",
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",