Skip to content

Commit 5e814fd

Browse files
author
codejunkie99
committed
feat: v0.7.0 — learn/recall/show tools + reliability pass
Three new host-agent tools ship: learn.py (one-shot lesson teaching), recall.py (proactive lesson retrieval with per-entry source labels), and show.py (colorful brain-state dashboard). All 8 adapter configs wire recall into their prompts so every harness can consult graduated lessons before deploy/migration/timestamp/debug/refactor work. Reliability pass: - Unify pattern_id across cluster.py and learn.py paths; canonicalize conditions (casefold, unicode whitespace, zero-width strip, dedupe). - heuristic_check: require ≥3 content words (blocks raw-length-gate bypass like "!!!abc"). - graduate.py retry: idempotent re-render, honor original metadata, refuse on legacy rows missing reviewer/rationale. - render_lessons + append_lesson now hold fcntl.LOCK_EX on lessons.jsonl; LESSONS.md rewrite is atomic via temp+rename. Seed UTC lesson ships pre-graduated so proactive-recall returns a hit on first install.
1 parent 2ea3a0e commit 5e814fd

21 files changed

Lines changed: 1402 additions & 37 deletions

File tree

.agent/AGENTS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ are the exact failure mode this layer prevents.
4646
- `protocols/tool_schemas/` — typed interfaces for external tools
4747
- `protocols/delegation.md` — rules for sub-agent handoff
4848

49+
## Host-agent CLI tools (in `tools/`)
50+
Daily driver, highest-leverage first:
51+
- `recall.py "<intent>"` — surface graduated lessons relevant to what
52+
you're about to do. **Run before deploy / migration / timestamp / debug /
53+
refactor work.** This is how lessons cross harnesses.
54+
- `learn.py "<rule>" --rationale "<why>"` — teach the agent a new lesson
55+
in one shot (stage + graduate + render). For rules you already know.
56+
- `show.py` — one-screen dashboard of brain state: episodes, candidates,
57+
lessons, failing skills, activity graph.
58+
- `list_candidates.py` / `graduate.py` / `reject.py` / `reopen.py` — review
59+
protocol for patterns the dream cycle has staged.
60+
- `memory_reflect.py <skill> <action> <outcome>` — log a significant event.
61+
4962
## Rules
5063
1. Check memory before decisions you have been corrected on before.
5164
2. If `REVIEW_QUEUE.md` shows backlog past threshold, handle it before the new task.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"id": "422695ae5b2d",
3+
"key": "manual_422695",
4+
"name": "manual_422695",
5+
"claim": "Always serialize timestamps in UTC to avoid cross-region comparison bugs",
6+
"conditions": [
7+
"always",
8+
"avoid",
9+
"bugs",
10+
"comparison",
11+
"cross-region",
12+
"serialize"
13+
],
14+
"evidence_ids": [
15+
"2026-04-19T22:09:32.170652"
16+
],
17+
"cluster_size": 1,
18+
"canonical_salience": 8.0,
19+
"staged_at": "2026-04-19T22:09:32.170652",
20+
"status": "accepted",
21+
"decisions": [
22+
{
23+
"ts": "2026-04-19T22:09:32.170652",
24+
"action": "staged",
25+
"reviewer": "learn"
26+
},
27+
{
28+
"ts": "2026-04-19T22:09:32.224058",
29+
"action": "graduated",
30+
"reviewer": "learn.py",
31+
"notes": "prior incidents with mixed local/UTC timestamps in order ledger",
32+
"provisional": false,
33+
"evidence_snapshot": [
34+
"2026-04-19T22:09:32.170652"
35+
],
36+
"lessons_sha": "5a313b2f2249"
37+
}
38+
],
39+
"rejection_count": 0,
40+
"accepted_at": "2026-04-19T22:09:32.224053",
41+
"reviewer": "learn.py",
42+
"rationale": "prior incidents with mixed local/UTC timestamps in order ledger"
43+
}

.agent/memory/cluster.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,56 @@ def _normalize_claim(text):
2424
return re.sub(r"\s+", " ", t).strip()
2525

2626

27+
_ZERO_WIDTH_RE = re.compile(r"[\u200b\u200c\u200d\u2060\ufeff]")
28+
_WHITESPACE_RE = re.compile(r"\s+", re.UNICODE)
29+
30+
31+
def _canonicalize_condition(c):
32+
"""Normalize a single condition token for stable hashing.
33+
34+
- strip zero-width characters (ZWSP, ZWNJ, ZWJ, WJ, BOM)
35+
- casefold (more aggressive than lower; handles e.g. ß → ss)
36+
- collapse ALL unicode whitespace (spaces, tabs, NBSP, etc.) to single ' '
37+
- outer strip
38+
Returns "" if nothing substantive remains.
39+
"""
40+
if not c:
41+
return ""
42+
c = _ZERO_WIDTH_RE.sub("", str(c))
43+
c = _WHITESPACE_RE.sub(" ", c).strip()
44+
return c.casefold()
45+
46+
47+
def pattern_id(claim, conditions):
48+
"""Stable content-derived pattern id. Single source of truth.
49+
50+
Same logical (claim, conditions) → same id. Conditions are canonicalized
51+
before hashing: case-folded, unicode-whitespace collapsed, zero-width
52+
characters stripped, outer-trimmed, empties dropped, deduplicated,
53+
sorted. So `['Alpha']`, `[' alpha ']`, `['alpha\\u200b']`, and
54+
`['alpha']` all hash the same.
55+
56+
What's NOT normalized: punctuation, hyphens, underscores. `'cross-region'`
57+
and `'cross_region'` are still distinct — they're likely distinct
58+
concepts. Callers who want them equivalent must pre-normalize.
59+
60+
Ids WILL still differ across birth paths for the same claim when
61+
conditions are genuinely different (cluster intersection vs user-provided
62+
vs full claim word_set). That's intentional: conditions are the stable
63+
signature of "under what circumstances does this claim hold," and
64+
different contexts deserve different lifecycle state.
65+
"""
66+
canonical = sorted({
67+
_canonicalize_condition(c)
68+
for c in (conditions or [])
69+
if _canonicalize_condition(c)
70+
})
71+
conditions_key = "|".join(canonical)
72+
return hashlib.md5(
73+
(_normalize_claim(claim) + "||" + conditions_key).encode()
74+
).hexdigest()[:12]
75+
76+
2777
def _entry_features(entry):
2878
"""Content feature set for clustering: action + reflection + detail."""
2979
text = " ".join([
@@ -100,11 +150,8 @@ def extract_pattern(cluster):
100150
# vocabulary), so lifecycle history carries across membership changes
101151
# in the common case while genuinely-different clusters with the same
102152
# canonical get distinct ids.
103-
conditions_key = "|".join(sorted(common))
104-
pattern_id = hashlib.md5(
105-
(_normalize_claim(claim) + "||" + conditions_key).encode()
106-
).hexdigest()[:12]
107-
name = f"pattern_{name_base}_{pattern_id[:6]}"
153+
pid = pattern_id(claim, sorted(common))
154+
name = f"pattern_{name_base}_{pid[:6]}"
108155

109156
# Recurrence-aware salience: give the scoring function cluster context
110157
# without mutating the source episode dict.
@@ -113,7 +160,7 @@ def extract_pattern(cluster):
113160
canonical_salience = salience_score(canonical_with_recurrence)
114161

115162
return {
116-
"id": pattern_id,
163+
"id": pid,
117164
"name": name,
118165
"claim": claim,
119166
"conditions": sorted(common),
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{"timestamp": "2026-04-20T02:31:42.923327", "skill": "claude-code", "action": "post-tool", "result": "success", "detail": "ok", "pain_score": 2, "importance": 5, "reflection": "", "confidence": 0.5, "source": {"skill": "claude-code", "profile": "default", "run_id": "pid-87590", "commit_sha": "2ea3a0ebe67c713e5e3aa0ff73bafc69bde57c77"}, "evidence_ids": []}
2+
{"timestamp": "2026-04-20T02:32:24.195381", "skill": "claude-code", "action": "post-tool", "result": "success", "detail": "ok", "pain_score": 2, "importance": 5, "reflection": "", "confidence": 0.5, "source": {"skill": "claude-code", "profile": "default", "run_id": "pid-87633", "commit_sha": "2ea3a0ebe67c713e5e3aa0ff73bafc69bde57c77"}, "evidence_ids": []}
3+
{"timestamp": "2026-04-20T02:32:38.318207", "skill": "claude-code", "action": "post-tool", "result": "success", "detail": "ok", "pain_score": 2, "importance": 5, "reflection": "", "confidence": 0.5, "source": {"skill": "claude-code", "profile": "default", "run_id": "pid-87658", "commit_sha": "2ea3a0ebe67c713e5e3aa0ff73bafc69bde57c77"}, "evidence_ids": []}
4+
{"timestamp": "2026-04-20T02:33:19.944616", "skill": "claude-code", "action": "post-tool", "result": "success", "detail": "ok", "pain_score": 2, "importance": 5, "reflection": "", "confidence": 0.5, "source": {"skill": "claude-code", "profile": "default", "run_id": "pid-87677", "commit_sha": "2ea3a0ebe67c713e5e3aa0ff73bafc69bde57c77"}, "evidence_ids": []}

.agent/memory/render_lessons.py

Lines changed: 116 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,17 @@
1010
from the sentinel onward. If the sentinel is missing it's appended at the
1111
end of the file. This means hand-curated preambles and seed bullets above
1212
the sentinel survive every render.
13+
14+
Concurrency: append_lesson and render_lessons both acquire an advisory
15+
exclusive flock on lessons.jsonl so a concurrent appender can't land a new
16+
row between render's load and write, leaving LESSONS.md stale. LESSONS.md
17+
is rewritten atomically (temp file + rename) so readers never see a
18+
half-written file. Windows (no fcntl) falls through without locking; safe
19+
for single-user, noted in a one-time warning.
1320
"""
14-
import os, json, datetime, hashlib
21+
import os, json, datetime, hashlib, warnings
1522
from collections import defaultdict
23+
from contextlib import contextmanager
1624

1725

1826
LESSONS_JSONL = "lessons.jsonl"
@@ -21,12 +29,72 @@
2129
SENTINEL = "## Auto-promoted entries will be appended below"
2230

2331

32+
try:
33+
import fcntl
34+
_HAS_FLOCK = True
35+
except ImportError:
36+
_HAS_FLOCK = False
37+
warnings.warn(
38+
"fcntl unavailable; lessons.jsonl concurrent-write protection "
39+
"disabled. Safe for single-user repos; not safe for shared/multi-"
40+
"process access.",
41+
RuntimeWarning,
42+
stacklevel=2,
43+
)
44+
45+
46+
@contextmanager
47+
def _locked_jsonl(path):
48+
"""Open lessons.jsonl with an advisory exclusive flock held for the scope.
49+
50+
Creates the file if missing ('a+' mode, which also permits read). The
51+
lock is process-level on Unix via fcntl.flock — two appenders serialize,
52+
and a render() call wrapping its entire read-render-write cycle in this
53+
lock blocks concurrent appenders until the render is done. Windows falls
54+
through without locking (see module-level warning).
55+
56+
Note: within a single process, opening the same path twice yields two
57+
separate fds with separate flock states, so nesting `_locked_jsonl`
58+
around another `_locked_jsonl` in the same thread will deadlock. Call
59+
`_append_lesson_unlocked(fd, lesson)` instead when already inside a
60+
lock (e.g. migrate_legacy_bullets is deliberately called OUTSIDE the
61+
render lock to sidestep this).
62+
"""
63+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
64+
f = open(path, "a+")
65+
try:
66+
if _HAS_FLOCK:
67+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
68+
yield f
69+
finally:
70+
if _HAS_FLOCK:
71+
try:
72+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
73+
except OSError:
74+
# Release-on-close is the kernel default; swallowing a late
75+
# release failure doesn't leak the lock.
76+
pass
77+
f.close()
78+
79+
80+
def _append_lesson_unlocked(f, lesson):
81+
"""Write a lesson row to an already-open, already-locked jsonl file.
82+
83+
Use this only when you already hold the lock (via `_locked_jsonl`).
84+
Seeks to end first because 'a+' mode tracks position across reads
85+
and the caller may have read from the head.
86+
"""
87+
f.seek(0, os.SEEK_END)
88+
f.write(json.dumps(lesson) + "\n")
89+
f.flush()
90+
91+
2492
def append_lesson(lesson, semantic_dir):
2593
"""Append a lesson to semantic/lessons.jsonl. Returns the written path."""
2694
os.makedirs(semantic_dir, exist_ok=True)
2795
path = os.path.join(semantic_dir, LESSONS_JSONL)
28-
with open(path, "a") as f:
29-
f.write(json.dumps(lesson) + "\n")
96+
with _locked_jsonl(path) as f:
97+
_append_lesson_unlocked(f, lesson)
3098
return path
3199

32100

@@ -182,33 +250,56 @@ def render_lessons(semantic_dir):
182250
lessons.jsonl before rendering, so upgrades from the old markdown-only
183251
format don't silently erase past promotions. Deduplicates entries by
184252
lesson id so a provisional-then-accepted lesson renders once, not twice.
253+
254+
Concurrency-safe: the entire read-render-write cycle runs under an
255+
exclusive flock on lessons.jsonl. A concurrent append_lesson() either
256+
lands BEFORE our load (we include it) or AFTER our write (it blocks
257+
on the flock, then will re-render on its own — graduate.py calls
258+
render_lessons right after appending). LESSONS.md is rewritten
259+
atomically via temp file + rename so readers never see a half-written
260+
file.
185261
"""
186-
# First, absorb any un-registered bullets below the sentinel
262+
# Migrate BEFORE taking the render lock. migrate_legacy_bullets calls
263+
# append_lesson internally, which acquires its own lock; nesting would
264+
# deadlock (two fds on the same file within one process each want
265+
# LOCK_EX). Migration is idempotent and only does real work on first
266+
# run after an upgrade, so the ordering is safe.
187267
migrate_legacy_bullets(semantic_dir)
188-
lessons = _dedupe_by_id(load_lessons(semantic_dir))
189-
auto_section = _build_auto_section(lessons)
190268

191-
path = os.path.join(semantic_dir, LESSONS_MD)
192-
193-
if os.path.exists(path):
194-
existing = open(path).read()
195-
if SENTINEL in existing:
196-
prefix = existing.split(SENTINEL)[0].rstrip()
197-
new = f"{prefix}\n\n{SENTINEL}\n\n{auto_section}"
198-
else:
199-
new = existing.rstrip() + f"\n\n{SENTINEL}\n\n{auto_section}"
200-
else:
201-
header = (
202-
"# Lessons\n\n"
203-
"> _Auto-managed below. Hand-curated preamble + seed lessons "
204-
"above the sentinel are preserved across renders._\n"
205-
)
206-
new = f"{header}\n{SENTINEL}\n\n{auto_section}"
269+
jsonl_path = os.path.join(semantic_dir, LESSONS_JSONL)
270+
md_path = os.path.join(semantic_dir, LESSONS_MD)
207271

208272
os.makedirs(semantic_dir, exist_ok=True)
209-
with open(path, "w") as f:
210-
f.write(new)
211-
return path
273+
274+
with _locked_jsonl(jsonl_path):
275+
lessons = _dedupe_by_id(load_lessons(semantic_dir))
276+
auto_section = _build_auto_section(lessons)
277+
278+
if os.path.exists(md_path):
279+
existing = open(md_path).read()
280+
if SENTINEL in existing:
281+
prefix = existing.split(SENTINEL)[0].rstrip()
282+
new = f"{prefix}\n\n{SENTINEL}\n\n{auto_section}"
283+
else:
284+
new = existing.rstrip() + f"\n\n{SENTINEL}\n\n{auto_section}"
285+
else:
286+
header = (
287+
"# Lessons\n\n"
288+
"> _Auto-managed below. Hand-curated preamble + seed lessons "
289+
"above the sentinel are preserved across renders._\n"
290+
)
291+
new = f"{header}\n{SENTINEL}\n\n{auto_section}"
292+
293+
# Atomic rewrite: write to .tmp next to the target, then rename.
294+
# os.replace is atomic on POSIX and Windows (Python 3.3+), so a
295+
# reader of LESSONS.md always sees either the old or the new
296+
# complete content, never a half-written file.
297+
tmp_path = md_path + ".tmp"
298+
with open(tmp_path, "w") as f:
299+
f.write(new)
300+
os.replace(tmp_path, md_path)
301+
302+
return md_path
212303

213304

214305
def render_lessons_as_text(semantic_dir):

.agent/memory/semantic/LESSONS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@
1212
- Never force push to protected branches under any circumstance.
1313

1414
## Auto-promoted entries will be appended below
15+
16+
### 2026-04
17+
18+
- Always serialize timestamps in UTC to avoid cross-region comparison bugs <!-- status=accepted confidence=0.46 evidence=1 id=lesson_422695ae5b2d -->
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"id": "lesson_422695ae5b2d", "claim": "Always serialize timestamps in UTC to avoid cross-region comparison bugs", "conditions": ["always", "avoid", "bugs", "comparison", "cross-region", "serialize"], "evidence_ids": ["2026-04-19T22:09:32.170652"], "status": "accepted", "accepted_at": "2026-04-19T22:09:32.223497", "reviewer": "learn.py", "rationale": "prior incidents with mixed local/UTC timestamps in order ledger", "cluster_size": 1, "canonical_salience": 8.0, "confidence": 0.46, "support_count": 0, "contradiction_count": 0, "supersedes": null, "source_candidate": "422695ae5b2d"}

.agent/memory/validate.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@
55
obvious junk — too-short claims, exact duplicates — before the reviewer
66
sees the candidate at all. Anything subjective is the host's job.
77
"""
8-
import re
8+
import os, re, sys
9+
10+
# Make harness/text.py importable for content-word counting. Codex review
11+
# caught that raw char-length gates let `!!!!!!!!!!!!!!!!!abc` pass — a
12+
# claim with exactly one real token. Content-word count is the real gate.
13+
_HARNESS = os.path.join(os.path.dirname(__file__), "..", "harness")
14+
if _HARNESS not in sys.path:
15+
sys.path.insert(0, _HARNESS)
16+
from text import word_set
917

1018
MIN_CLAIM_LEN = 20
19+
MIN_CONTENT_WORDS = 3 # minimum non-stopword tokens for a meaningful claim
1120
LENGTH_SATURATE = 100
1221
CLUSTER_SATURATE = 5
1322

@@ -77,6 +86,15 @@ def heuristic_check(candidate, existing_lessons_md=""):
7786
if len(claim) < MIN_CLAIM_LEN:
7887
reasons.append("claim_too_short")
7988

89+
# Content-word gate: raw char length alone let garbage like
90+
# `!!!!!!!!!!!!!!!!!abc` through. A real lesson needs at least three
91+
# non-stopword tokens so the reviewer has something substantive to
92+
# evaluate and retrieval has hooks to score on.
93+
content_words = word_set(claim)
94+
if len(content_words) < MIN_CONTENT_WORDS:
95+
reasons.append(
96+
f"insufficient_content_words_{len(content_words)}_of_{MIN_CONTENT_WORDS}")
97+
8098
if claim:
8199
duplicates = check_exact_duplicate(claim, existing_lessons_md)
82100
if duplicates:

0 commit comments

Comments
 (0)