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.
package/open_dream_rsi/cli.py
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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(
|
|
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) +
|
|
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")]
|
package/open_dream_rsi/dream.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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,
|
|
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.
|
|
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",
|