agent-bios 0.4.0 → 0.7.0
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/claude/CLAUDE.md +4 -0
- package/claude/guides/learning-flow.md +106 -0
- package/codex/AGENTS.md +4 -0
- package/codex/guides/learning-flow.md +106 -0
- package/config/domains.json +150 -0
- package/config/learning.schema.json +104 -0
- package/config/promotions.json +4 -0
- package/package.json +8 -1
- package/scripts/check-learning.py +231 -0
- package/scripts/check-parity.sh +36 -0
- package/scripts/collect-learning.py +600 -0
- package/scripts/install.sh +19 -0
- package/scripts/migrate-learnings.py +423 -0
- package/scripts/redact.py +91 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic submit tool for the session learning flow (trigger `learn!`).
|
|
3
|
+
|
|
4
|
+
Capability boundary (design/collection-loop/DESIGN.md Phase 1 step 4): the LLM
|
|
5
|
+
supplies ONLY the semantic payload (lesson, domain, supporting_sessions, and the
|
|
6
|
+
optional criteria / classification / proposed_domain / context) plus its session
|
|
7
|
+
`--host`; THIS script owns every deterministic value and side effect:
|
|
8
|
+
|
|
9
|
+
* mints learning_id (a lowercase UUID) + created (ISO-8601) + schema_version;
|
|
10
|
+
* validates the full record against config/learning.schema.json — the single
|
|
11
|
+
validation source, reused from scripts/check-learning.py (no second schema);
|
|
12
|
+
* logs the JSON record to <home>/personal/learnings.jsonl — the durable,
|
|
13
|
+
append-only upload source that the Phase 2 watermark drain re-sends from;
|
|
14
|
+
* writes the lesson prose where THIS host loads it next session, and wires it:
|
|
15
|
+
- claude: appends to <home>/personal/learnings.md and ensures the
|
|
16
|
+
`@personal/learnings.md` import once in the entry CLAUDE.md;
|
|
17
|
+
- codex: appends into a preserved `agent-bios:personal-learnings` region
|
|
18
|
+
of <home>/AGENTS.md (Codex has no @import; AGENTS.md is always loaded).
|
|
19
|
+
The region lives OUTSIDE the central markers so scripts/assemble.py —
|
|
20
|
+
which only rewrites the central region — preserves it across re-assembly.
|
|
21
|
+
|
|
22
|
+
It REFUSES any script-owned field in the payload (deterministic values are never
|
|
23
|
+
hand-authored) and, on a schema/membership violation, REJECTS and exits 1 — it
|
|
24
|
+
never patches the payload to make it pass (runtime enforces, does not reason).
|
|
25
|
+
|
|
26
|
+
Input: the semantic payload as one JSON object on stdin.
|
|
27
|
+
Host: --host claude|codex (default: the tool prefix of supporting_sessions[0]).
|
|
28
|
+
Home: --config-dir, else $CLAUDE_CONFIG_DIR / $CODEX_HOME by host, else ~/.claude / ~/.codex.
|
|
29
|
+
After the local writes it best-effort uploads not-yet-delivered records to
|
|
30
|
+
{ingest-url}/api/ingest/learnings via a watermark over learnings.jsonl (Phase 2
|
|
31
|
+
transport; skipped when the dashboard hook is not installed, or with --no-upload).
|
|
32
|
+
"""
|
|
33
|
+
import argparse
|
|
34
|
+
import datetime
|
|
35
|
+
import importlib.util
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import pathlib
|
|
39
|
+
import shutil
|
|
40
|
+
import sys
|
|
41
|
+
import time
|
|
42
|
+
import urllib.error
|
|
43
|
+
import urllib.request
|
|
44
|
+
import uuid
|
|
45
|
+
|
|
46
|
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
47
|
+
OWNED_FIELDS = ("schema_version", "learning_id", "created")
|
|
48
|
+
# Free-text the LLM authored — scrubbed through the shared secret-redaction floor
|
|
49
|
+
# at capture, so secrets never reach the durable log, the upload, or the curator
|
|
50
|
+
# export (design/collection-loop/PHASE3-CURATION-DESIGN.md; the corpus floor in
|
|
51
|
+
# design/corpus-domain-packaging.md). Pattern-locked fields (domain,
|
|
52
|
+
# supporting_sessions, criteria) carry no free text and are left untouched.
|
|
53
|
+
FREE_TEXT_FIELDS = ("lesson", "context")
|
|
54
|
+
|
|
55
|
+
# host -> (config-home env var, default home dirname under $HOME)
|
|
56
|
+
HOSTS = {
|
|
57
|
+
"claude": ("CLAUDE_CONFIG_DIR", ".claude"),
|
|
58
|
+
"codex": ("CODEX_HOME", ".codex"),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
CLAUDE_IMPORT_LINE = "@personal/learnings.md"
|
|
62
|
+
CLAUDE_CENTRAL_IMPORT = "@central/bundle.md"
|
|
63
|
+
|
|
64
|
+
# Codex AGENTS.md markers. The central pair is owned by scripts/assemble.py
|
|
65
|
+
# (kept in sync here); the personal-learnings pair is this tool's own region,
|
|
66
|
+
# placed outside the central pair so re-assembly preserves it.
|
|
67
|
+
CENTRAL_START = "<!-- agent-bios:central:start -->"
|
|
68
|
+
CENTRAL_END = "<!-- agent-bios:central:end -->"
|
|
69
|
+
PERSONAL_START = "<!-- agent-bios:personal-learnings:start -->"
|
|
70
|
+
PERSONAL_END = "<!-- agent-bios:personal-learnings:end -->"
|
|
71
|
+
|
|
72
|
+
CLAUDE_LEARNINGS_HEADER = """# Personal learnings
|
|
73
|
+
|
|
74
|
+
<!-- Automation-owned: written by the session learning flow (`learn!`,
|
|
75
|
+
scripts/collect-learning.py). Do NOT hand-edit — promote→migrate clears
|
|
76
|
+
applied items by learning_id when the org redistributes them. Your own
|
|
77
|
+
personal rules belong in the entry CLAUDE.md '## Personal' section, never
|
|
78
|
+
here. This file is pulled into context by the entry file's
|
|
79
|
+
`@personal/learnings.md` import. -->
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
CODEX_REGION_HEADER = """## Personal learnings
|
|
83
|
+
<!-- Automation-owned: written by the session learning flow (`learn!`,
|
|
84
|
+
scripts/collect-learning.py). Codex loads this via AGENTS.md (no @import).
|
|
85
|
+
Do NOT hand-edit — promote→migrate clears applied items by learning_id.
|
|
86
|
+
Kept outside the agent-bios central markers so re-assembly preserves it. -->
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_checker():
|
|
91
|
+
"""Reuse scripts/check-learning.py as the single validation source."""
|
|
92
|
+
path = REPO / "scripts" / "check-learning.py"
|
|
93
|
+
spec = importlib.util.spec_from_file_location("check_learning", path)
|
|
94
|
+
module = importlib.util.module_from_spec(spec)
|
|
95
|
+
spec.loader.exec_module(module)
|
|
96
|
+
return module
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_redactor():
|
|
100
|
+
"""Reuse scripts/redact.py as the single secret-redaction floor (loaded by
|
|
101
|
+
path so it works from the npm bin regardless of cwd, like load_checker)."""
|
|
102
|
+
path = REPO / "scripts" / "redact.py"
|
|
103
|
+
spec = importlib.util.spec_from_file_location("redact", path)
|
|
104
|
+
module = importlib.util.module_from_spec(spec)
|
|
105
|
+
spec.loader.exec_module(module)
|
|
106
|
+
return module
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def die(msg, code=1):
|
|
110
|
+
print(f"collect-learning: {msg}", file=sys.stderr)
|
|
111
|
+
sys.exit(code)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_payload():
|
|
115
|
+
raw = sys.stdin.read()
|
|
116
|
+
if not raw.strip():
|
|
117
|
+
die("no payload on stdin (expected one JSON object with the semantic fields)")
|
|
118
|
+
try:
|
|
119
|
+
payload = json.loads(raw)
|
|
120
|
+
except json.JSONDecodeError as e:
|
|
121
|
+
die(f"payload is not valid JSON: {e}")
|
|
122
|
+
if not isinstance(payload, dict):
|
|
123
|
+
die("payload must be a JSON object")
|
|
124
|
+
present_owned = [f for f in OWNED_FIELDS if f in payload]
|
|
125
|
+
if present_owned:
|
|
126
|
+
die(f"payload must not carry script-owned field(s) {present_owned} — "
|
|
127
|
+
"collect-learning mints schema_version/learning_id/created itself")
|
|
128
|
+
return payload
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_host(cli_host, payload):
|
|
132
|
+
if cli_host:
|
|
133
|
+
return cli_host
|
|
134
|
+
sessions = payload.get("supporting_sessions")
|
|
135
|
+
if isinstance(sessions, list) and sessions and isinstance(sessions[0], str) and ":" in sessions[0]:
|
|
136
|
+
tool = sessions[0].split(":", 1)[0]
|
|
137
|
+
if tool in HOSTS:
|
|
138
|
+
return tool
|
|
139
|
+
die("cannot determine host — pass --host claude|codex "
|
|
140
|
+
"(or a supporting_sessions entry prefixed 'claude:'/'codex:')")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_home(host, config_dir):
|
|
144
|
+
if config_dir:
|
|
145
|
+
return pathlib.Path(config_dir)
|
|
146
|
+
env_var, default_name = HOSTS[host]
|
|
147
|
+
return pathlib.Path(os.environ.get(env_var) or pathlib.Path.home() / default_name)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_record(payload):
|
|
151
|
+
redactor = load_redactor()
|
|
152
|
+
record = dict(payload)
|
|
153
|
+
for field in FREE_TEXT_FIELDS:
|
|
154
|
+
if isinstance(record.get(field), str):
|
|
155
|
+
record[field] = redactor.redact(record[field])
|
|
156
|
+
# The personal prose bullet is ONE line (prose_bullet); a newline in lesson
|
|
157
|
+
# would split it and defeat promote->migrate's by-learning_id bullet prune,
|
|
158
|
+
# so collapse newlines here at capture (context is jsonl-only, left as-is).
|
|
159
|
+
lesson = record.get("lesson")
|
|
160
|
+
if isinstance(lesson, str) and ("\n" in lesson or "\r" in lesson):
|
|
161
|
+
record["lesson"] = lesson.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
|
|
162
|
+
record["schema_version"] = 1
|
|
163
|
+
record["learning_id"] = str(uuid.uuid4()) # canonical lowercase
|
|
164
|
+
record["created"] = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat()
|
|
165
|
+
return record
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def validate(record):
|
|
169
|
+
checker = load_checker()
|
|
170
|
+
validator = checker.build_validator()
|
|
171
|
+
domain_values = checker.valid_domain_values()
|
|
172
|
+
return checker.validate_record(record, validator, domain_values)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def prose_bullet(record):
|
|
176
|
+
return (f"- [{record['domain']}] {record['lesson']} "
|
|
177
|
+
f"<!-- learning_id: {record['learning_id']} created: {record['created']} -->")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def backup(path):
|
|
181
|
+
shutil.copy2(path, path.with_suffix(path.suffix + f".bak-learn-{time.strftime('%Y%m%d-%H%M%S')}"))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def append_record(home, record, dry):
|
|
185
|
+
jsonl = home / "personal" / "learnings.jsonl"
|
|
186
|
+
if dry:
|
|
187
|
+
print(f" [dry] append JSON record to {jsonl}")
|
|
188
|
+
return jsonl
|
|
189
|
+
jsonl.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
with open(jsonl, "a", encoding="utf-8") as f:
|
|
191
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
192
|
+
return jsonl
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---- claude: personal/learnings.md + @personal/learnings.md import ----------
|
|
196
|
+
|
|
197
|
+
def apply_claude(home, bullet, dry):
|
|
198
|
+
md = home / "personal" / "learnings.md"
|
|
199
|
+
if dry:
|
|
200
|
+
if not md.exists():
|
|
201
|
+
print(f" [dry] create {md}")
|
|
202
|
+
print(f" [dry] append prose to {md}")
|
|
203
|
+
else:
|
|
204
|
+
md.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
if not md.exists():
|
|
206
|
+
md.write_text(CLAUDE_LEARNINGS_HEADER, encoding="utf-8")
|
|
207
|
+
with open(md, "a", encoding="utf-8") as f:
|
|
208
|
+
f.write(bullet + "\n")
|
|
209
|
+
import_state = ensure_claude_import(home, dry)
|
|
210
|
+
return md, f"entry import ({home / 'CLAUDE.md'}): {import_state}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def ensure_claude_import(home, dry):
|
|
214
|
+
entry = home / "CLAUDE.md"
|
|
215
|
+
if not entry.exists():
|
|
216
|
+
if dry:
|
|
217
|
+
print(f" [dry] create entry {entry} with import line")
|
|
218
|
+
return "created"
|
|
219
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
entry.write_text(f"# CLAUDE.md\n\n{CLAUDE_IMPORT_LINE}\n", encoding="utf-8")
|
|
221
|
+
return "created"
|
|
222
|
+
body = entry.read_text(encoding="utf-8")
|
|
223
|
+
if CLAUDE_IMPORT_LINE in body:
|
|
224
|
+
return "present"
|
|
225
|
+
if dry:
|
|
226
|
+
print(f" [dry] insert '{CLAUDE_IMPORT_LINE}' into {entry}")
|
|
227
|
+
return "inserted"
|
|
228
|
+
lines = body.splitlines(keepends=True)
|
|
229
|
+
idx = next((i for i, ln in enumerate(lines) if CLAUDE_CENTRAL_IMPORT in ln), 0)
|
|
230
|
+
backup(entry)
|
|
231
|
+
lines.insert(idx + 1, CLAUDE_IMPORT_LINE + "\n")
|
|
232
|
+
entry.write_text("".join(lines), encoding="utf-8")
|
|
233
|
+
return "inserted"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---- codex: personal-learnings region inside AGENTS.md ----------------------
|
|
237
|
+
|
|
238
|
+
def apply_codex(home, bullet, dry):
|
|
239
|
+
agents = home / "AGENTS.md"
|
|
240
|
+
if agents.exists():
|
|
241
|
+
body = agents.read_text(encoding="utf-8")
|
|
242
|
+
else:
|
|
243
|
+
# Degenerate (corpus not installed): seed EMPTY central markers so a
|
|
244
|
+
# later assemble fills them and keeps our region in the preserved tail.
|
|
245
|
+
body = f"{CENTRAL_START}\n{CENTRAL_END}\n"
|
|
246
|
+
if PERSONAL_START in body and PERSONAL_END in body:
|
|
247
|
+
pre, rest = body.split(PERSONAL_START, 1)
|
|
248
|
+
region_body, post = rest.split(PERSONAL_END, 1)
|
|
249
|
+
new_body = region_body.rstrip("\n") + "\n" + bullet + "\n"
|
|
250
|
+
new = f"{pre}{PERSONAL_START}{new_body}{PERSONAL_END}{post}"
|
|
251
|
+
else:
|
|
252
|
+
region = f"\n{PERSONAL_START}\n{CODEX_REGION_HEADER}{bullet}\n{PERSONAL_END}\n"
|
|
253
|
+
new = body.rstrip("\n") + "\n" + region
|
|
254
|
+
if dry:
|
|
255
|
+
print(f" [dry] write personal-learnings region in {agents}")
|
|
256
|
+
return agents, f"AGENTS.md region ({agents}): updated"
|
|
257
|
+
agents.parent.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
if agents.exists():
|
|
259
|
+
backup(agents)
|
|
260
|
+
agents.write_text(new, encoding="utf-8")
|
|
261
|
+
return agents, f"AGENTS.md region ({agents}): updated"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
APPLY = {"claude": apply_claude, "codex": apply_codex}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ── Phase 2 transport: watermark upload over the durable learnings.jsonl ───────
|
|
268
|
+
#
|
|
269
|
+
# collect-learning runs on-demand (per `learn!`), so the upload piggybacks here:
|
|
270
|
+
# after the local writes, best-effort POST any not-yet-settled records to
|
|
271
|
+
# {ingest-url}/api/ingest/learnings and record which learning_ids are settled in
|
|
272
|
+
# a small state file (the watermark). The durable learnings.jsonl is the single
|
|
273
|
+
# source and is never capped, so nothing is lost across a multi-day server outage
|
|
274
|
+
# — unsettled records simply retry on the next `learn!`. A 2xx (incl. a duplicate
|
|
275
|
+
# re-send, which the server dedups by learning_id) settles a record; 400/413
|
|
276
|
+
# settle it as permanently rejected; any other status (401/403/404/429/503/5xx/
|
|
277
|
+
# network) is transient and stops the drain (no retry storm) to retry next time.
|
|
278
|
+
# (design/collection-loop/PHASE2-ENDPOINT-DESIGN.md D2.5.)
|
|
279
|
+
|
|
280
|
+
INGEST_PATH = "/api/ingest/learnings"
|
|
281
|
+
STATE_NAME = ".learnings-upload-state.json"
|
|
282
|
+
UPLOAD_LIMIT = 25 # max POSTs per invocation
|
|
283
|
+
UPLOAD_BUDGET_S = 5.0 # total wall-clock budget for the whole drain
|
|
284
|
+
UPLOAD_TIMEOUT_S = 3.0 # per-request socket timeout
|
|
285
|
+
PERMANENT_STATUSES = frozenset({400, 413}) # never succeeds → settle as dead
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def transport_config(home):
|
|
289
|
+
"""Read the dashboard hook's token + ingest base URL (READ-ONLY; the hook dir
|
|
290
|
+
is dashboard-owned — we never write it). Returns ((token, base), None) or
|
|
291
|
+
(None, reason) when the hook is not installed (Q-E: caller prints a notice)."""
|
|
292
|
+
hook_dir = home / "hooks"
|
|
293
|
+
token_file = hook_dir / "token"
|
|
294
|
+
if not token_file.is_file():
|
|
295
|
+
return None, "dashboard hook not installed (no token)"
|
|
296
|
+
try:
|
|
297
|
+
token = token_file.read_text(encoding="utf-8").strip()
|
|
298
|
+
except OSError as e:
|
|
299
|
+
return None, f"cannot read hook token ({e})"
|
|
300
|
+
if not token:
|
|
301
|
+
return None, "dashboard hook token is empty"
|
|
302
|
+
base = ""
|
|
303
|
+
# The installer writes `dashboard-url`; a migrated install may also have
|
|
304
|
+
# `ingest-url`. Prefer ingest-url, fall back to dashboard-url.
|
|
305
|
+
for name in ("ingest-url", "dashboard-url"):
|
|
306
|
+
f = hook_dir / name
|
|
307
|
+
if f.is_file():
|
|
308
|
+
candidate = f.read_text(encoding="utf-8").strip()
|
|
309
|
+
if candidate:
|
|
310
|
+
base = candidate
|
|
311
|
+
break
|
|
312
|
+
if not base:
|
|
313
|
+
return None, "dashboard hook ingest URL not found"
|
|
314
|
+
return (token, base.rstrip("/")), None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def read_jsonl_records(jsonl):
|
|
318
|
+
"""(learning_id, record) for each well-formed line; malformed lines are
|
|
319
|
+
skipped so a single poison line never wedges the drain."""
|
|
320
|
+
out = []
|
|
321
|
+
if not jsonl.is_file():
|
|
322
|
+
return out
|
|
323
|
+
with open(jsonl, encoding="utf-8") as f:
|
|
324
|
+
for line in f:
|
|
325
|
+
line = line.strip()
|
|
326
|
+
if not line:
|
|
327
|
+
continue
|
|
328
|
+
try:
|
|
329
|
+
rec = json.loads(line)
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
continue
|
|
332
|
+
lid = rec.get("learning_id") if isinstance(rec, dict) else None
|
|
333
|
+
if isinstance(lid, str) and lid:
|
|
334
|
+
out.append((lid, rec))
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def load_state(home):
|
|
339
|
+
path = home / "personal" / STATE_NAME
|
|
340
|
+
if not path.is_file():
|
|
341
|
+
return {"uploaded": set(), "dead": set()}
|
|
342
|
+
try:
|
|
343
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
344
|
+
return {"uploaded": set(data.get("uploaded", [])),
|
|
345
|
+
"dead": set(data.get("dead", []))}
|
|
346
|
+
except (json.JSONDecodeError, OSError):
|
|
347
|
+
return {"uploaded": set(), "dead": set()}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def save_state(home, state, present_ids):
|
|
351
|
+
# Prune settled ids no longer in the durable log (e.g. migrated out) so the
|
|
352
|
+
# state stays bounded to the log size. Atomic replace.
|
|
353
|
+
path = home / "personal" / STATE_NAME
|
|
354
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
data = {"uploaded": sorted(state["uploaded"] & present_ids),
|
|
356
|
+
"dead": sorted(state["dead"] & present_ids)}
|
|
357
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
358
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
359
|
+
os.replace(tmp, path)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def classify_status(status):
|
|
363
|
+
if status is None:
|
|
364
|
+
return "transient" # network error / timeout
|
|
365
|
+
if 200 <= status < 300:
|
|
366
|
+
return "ok"
|
|
367
|
+
if status in PERMANENT_STATUSES:
|
|
368
|
+
return "permanent"
|
|
369
|
+
return "transient" # 401/403/404/429/503/5xx → retry next time
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def http_post(base, token, record, timeout=UPLOAD_TIMEOUT_S):
|
|
373
|
+
"""POST one record; return the HTTP status int, or None on a network error."""
|
|
374
|
+
body = json.dumps(record, ensure_ascii=False).encode("utf-8")
|
|
375
|
+
req = urllib.request.Request(base + INGEST_PATH, data=body, method="POST")
|
|
376
|
+
req.add_header("Content-Type", "application/json")
|
|
377
|
+
req.add_header("X-Hook-Token", token)
|
|
378
|
+
try:
|
|
379
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
380
|
+
return resp.status
|
|
381
|
+
except urllib.error.HTTPError as e:
|
|
382
|
+
return e.code
|
|
383
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def drain_uploads(home, post_fn=http_post, now=time.monotonic,
|
|
388
|
+
limit=UPLOAD_LIMIT, budget_s=UPLOAD_BUDGET_S):
|
|
389
|
+
"""Best-effort upload of not-yet-settled learnings. post_fn/now are injectable
|
|
390
|
+
for the self-test (no sockets). Returns a summary dict."""
|
|
391
|
+
config, reason = transport_config(home)
|
|
392
|
+
if config is None:
|
|
393
|
+
return {"status": "skipped", "reason": reason,
|
|
394
|
+
"uploaded": 0, "dead": 0, "pending": None}
|
|
395
|
+
token, base = config
|
|
396
|
+
|
|
397
|
+
records = read_jsonl_records(home / "personal" / "learnings.jsonl")
|
|
398
|
+
present_ids = {lid for lid, _ in records}
|
|
399
|
+
state = load_state(home)
|
|
400
|
+
settled = state["uploaded"] | state["dead"]
|
|
401
|
+
candidates = [(lid, rec) for lid, rec in records if lid not in settled]
|
|
402
|
+
|
|
403
|
+
deadline = now() + budget_s
|
|
404
|
+
uploaded = dead = sent = 0
|
|
405
|
+
stopped = None
|
|
406
|
+
for lid, rec in candidates:
|
|
407
|
+
if sent >= limit:
|
|
408
|
+
stopped = "limit"
|
|
409
|
+
break
|
|
410
|
+
if now() >= deadline:
|
|
411
|
+
stopped = "budget"
|
|
412
|
+
break
|
|
413
|
+
kind = classify_status(post_fn(base, token, rec))
|
|
414
|
+
sent += 1
|
|
415
|
+
if kind == "ok":
|
|
416
|
+
state["uploaded"].add(lid)
|
|
417
|
+
uploaded += 1
|
|
418
|
+
elif kind == "permanent":
|
|
419
|
+
state["dead"].add(lid)
|
|
420
|
+
dead += 1
|
|
421
|
+
else: # transient — server likely down / systemic; stop, retry next time
|
|
422
|
+
stopped = "transient"
|
|
423
|
+
break
|
|
424
|
+
|
|
425
|
+
save_state(home, state, present_ids)
|
|
426
|
+
pending = sum(1 for lid, _ in records
|
|
427
|
+
if lid not in (state["uploaded"] | state["dead"]))
|
|
428
|
+
return {"status": "ok", "reason": stopped, "uploaded": uploaded,
|
|
429
|
+
"dead": dead, "pending": pending}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def print_upload_summary(s):
|
|
433
|
+
if s["status"] == "skipped":
|
|
434
|
+
print(f" upload -> skipped ({s['reason']}); "
|
|
435
|
+
"retries on a later learn! once the hook is installed")
|
|
436
|
+
return
|
|
437
|
+
tail = f" (stopped: {s['reason']})" if s["reason"] else ""
|
|
438
|
+
print(f" upload -> uploaded={s['uploaded']} dead={s['dead']} "
|
|
439
|
+
f"pending={s['pending']}{tail}")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _self_test():
|
|
443
|
+
"""Socket-free verification of the drain: status classification, watermark
|
|
444
|
+
advance, transient-stop (no storm), permanent-drop, no-token skip, poison
|
|
445
|
+
line, plus capture-time redaction wiring. Injects post_fn/now; exits
|
|
446
|
+
non-zero on any failure."""
|
|
447
|
+
import tempfile
|
|
448
|
+
|
|
449
|
+
def make_home(records, with_token=True):
|
|
450
|
+
home = pathlib.Path(tempfile.mkdtemp(prefix="learn-selftest-"))
|
|
451
|
+
(home / "personal").mkdir(parents=True)
|
|
452
|
+
with open(home / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
|
|
453
|
+
for r in records:
|
|
454
|
+
f.write(r if isinstance(r, str) else json.dumps(r))
|
|
455
|
+
f.write("\n")
|
|
456
|
+
if with_token:
|
|
457
|
+
(home / "hooks").mkdir(parents=True)
|
|
458
|
+
(home / "hooks" / "token").write_text("tok", encoding="utf-8")
|
|
459
|
+
(home / "hooks" / "dashboard-url").write_text(
|
|
460
|
+
"https://example.test/", encoding="utf-8")
|
|
461
|
+
return home
|
|
462
|
+
|
|
463
|
+
def rec(n):
|
|
464
|
+
return {"learning_id": f"0f8c1c2a-4d1e-4abc-9def-{n:012d}",
|
|
465
|
+
"schema_version": 1, "lesson": "x" * 12, "domain": "core",
|
|
466
|
+
"created": "2026-07-20T00:00:00Z",
|
|
467
|
+
"supporting_sessions": ["claude:abcd1234"]}
|
|
468
|
+
|
|
469
|
+
checks = []
|
|
470
|
+
|
|
471
|
+
# 1) no token → skipped, no state file written (watermark unadvanced).
|
|
472
|
+
h = make_home([rec(1)], with_token=False)
|
|
473
|
+
s = drain_uploads(h)
|
|
474
|
+
checks.append(("no-token skip", s["status"] == "skipped"
|
|
475
|
+
and not (h / "personal" / STATE_NAME).is_file()))
|
|
476
|
+
|
|
477
|
+
# 2) all ok → all uploaded, pending 0; re-run uploads nothing new.
|
|
478
|
+
h = make_home([rec(1), rec(2), rec(3)])
|
|
479
|
+
s = drain_uploads(h, post_fn=lambda *a: 200)
|
|
480
|
+
calls = {"n": 0}
|
|
481
|
+
def count_post(*a):
|
|
482
|
+
calls["n"] += 1
|
|
483
|
+
return 200
|
|
484
|
+
s2 = drain_uploads(h, post_fn=count_post)
|
|
485
|
+
checks.append(("all-ok then idempotent re-run",
|
|
486
|
+
s["uploaded"] == 3 and s["pending"] == 0
|
|
487
|
+
and calls["n"] == 0 and s2["uploaded"] == 0))
|
|
488
|
+
|
|
489
|
+
# 3) transient (500) → nothing settled, drain stops, pending == N; a later
|
|
490
|
+
# 200 run delivers everything (multi-day-outage recovery).
|
|
491
|
+
h = make_home([rec(1), rec(2)])
|
|
492
|
+
s = drain_uploads(h, post_fn=lambda *a: 500)
|
|
493
|
+
s2 = drain_uploads(h, post_fn=lambda *a: 200)
|
|
494
|
+
checks.append(("transient stop then recover",
|
|
495
|
+
s["uploaded"] == 0 and s["pending"] == 2
|
|
496
|
+
and s["reason"] == "transient"
|
|
497
|
+
and s2["uploaded"] == 2 and s2["pending"] == 0))
|
|
498
|
+
|
|
499
|
+
# 4) permanent (400) → settled as dead, not retried.
|
|
500
|
+
h = make_home([rec(1)])
|
|
501
|
+
s = drain_uploads(h, post_fn=lambda *a: 400)
|
|
502
|
+
hits = {"n": 0}
|
|
503
|
+
def once(*a):
|
|
504
|
+
hits["n"] += 1
|
|
505
|
+
return 400
|
|
506
|
+
s2 = drain_uploads(h, post_fn=once)
|
|
507
|
+
checks.append(("permanent drop, not retried",
|
|
508
|
+
s["dead"] == 1 and s["pending"] == 0 and hits["n"] == 0))
|
|
509
|
+
|
|
510
|
+
# 5) network error (None) is transient.
|
|
511
|
+
h = make_home([rec(1)])
|
|
512
|
+
s = drain_uploads(h, post_fn=lambda *a: None)
|
|
513
|
+
checks.append(("network error is transient",
|
|
514
|
+
s["uploaded"] == 0 and s["pending"] == 1))
|
|
515
|
+
|
|
516
|
+
# 6) poison line skipped, valid records still delivered.
|
|
517
|
+
h = make_home(["{ not json", rec(1), ""])
|
|
518
|
+
s = drain_uploads(h, post_fn=lambda *a: 200)
|
|
519
|
+
checks.append(("poison line skipped", s["uploaded"] == 1 and s["pending"] == 0))
|
|
520
|
+
|
|
521
|
+
# 7) wall-clock budget stops the drain (fake clock jumps past the deadline).
|
|
522
|
+
clock = {"t": 0.0}
|
|
523
|
+
def fake_now():
|
|
524
|
+
clock["t"] += 10.0
|
|
525
|
+
return clock["t"]
|
|
526
|
+
h = make_home([rec(1), rec(2)])
|
|
527
|
+
s = drain_uploads(h, post_fn=lambda *a: 200, now=fake_now, budget_s=5.0)
|
|
528
|
+
checks.append(("budget stop", s["reason"] == "budget"))
|
|
529
|
+
|
|
530
|
+
# 8) capture-time secret redaction is wired into build_record, so secrets in
|
|
531
|
+
# free text never reach the durable log / upload / curator export.
|
|
532
|
+
red = build_record({"lesson": "leaked api_key=sk_live_0123456789ABCDEF here",
|
|
533
|
+
"context": "ping dev@example.com about it",
|
|
534
|
+
"domain": "core", "supporting_sessions": ["claude:abcd1234"]})
|
|
535
|
+
checks.append(("capture redaction wiring",
|
|
536
|
+
"sk_live_" not in red["lesson"] and "<REDACTED>" in red["lesson"]
|
|
537
|
+
and "dev@example.com" not in red["context"]))
|
|
538
|
+
|
|
539
|
+
# 9) lesson newlines collapse at capture (keeps the personal bullet 1 line).
|
|
540
|
+
nl = build_record({"lesson": "line one\nline two\r\nline three", "domain": "core",
|
|
541
|
+
"supporting_sessions": ["claude:abcd1234"]})
|
|
542
|
+
checks.append(("lesson newlines collapsed",
|
|
543
|
+
"\n" not in nl["lesson"] and "\r" not in nl["lesson"]
|
|
544
|
+
and "line one line two line three" == nl["lesson"]))
|
|
545
|
+
|
|
546
|
+
failed = [name for name, ok in checks if not ok]
|
|
547
|
+
if failed:
|
|
548
|
+
for name in failed:
|
|
549
|
+
print(f"collect-learning --self-test: FAIL: {name}", file=sys.stderr)
|
|
550
|
+
sys.exit(1)
|
|
551
|
+
print(f"collect-learning --self-test: OK ({len(checks)} upload-drain checks)")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def main():
|
|
555
|
+
ap = argparse.ArgumentParser(description="Submit a session learning (learn!).")
|
|
556
|
+
ap.add_argument("--host", choices=sorted(HOSTS),
|
|
557
|
+
help="session host (default: tool prefix of supporting_sessions[0])")
|
|
558
|
+
ap.add_argument("--config-dir", default=None,
|
|
559
|
+
help="config home (default: $CLAUDE_CONFIG_DIR / $CODEX_HOME by host)")
|
|
560
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
561
|
+
help="validate and print actions; write nothing")
|
|
562
|
+
ap.add_argument("--no-upload", action="store_true",
|
|
563
|
+
help="skip the Phase 2 upload (local writes only)")
|
|
564
|
+
ap.add_argument("--self-test", action="store_true",
|
|
565
|
+
help="run the upload-drain self-test and exit")
|
|
566
|
+
args = ap.parse_args()
|
|
567
|
+
|
|
568
|
+
if args.self_test:
|
|
569
|
+
_self_test()
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
payload = read_payload()
|
|
573
|
+
host = resolve_host(args.host, payload)
|
|
574
|
+
home = resolve_home(host, args.config_dir)
|
|
575
|
+
record = build_record(payload)
|
|
576
|
+
|
|
577
|
+
errors = validate(record)
|
|
578
|
+
if errors:
|
|
579
|
+
print("collect-learning: REJECTED (record is not valid; not written)", file=sys.stderr)
|
|
580
|
+
for e in errors:
|
|
581
|
+
print(f" - {e}", file=sys.stderr)
|
|
582
|
+
sys.exit(1)
|
|
583
|
+
|
|
584
|
+
dry = args.dry_run
|
|
585
|
+
bullet = prose_bullet(record)
|
|
586
|
+
jsonl = append_record(home, record, dry)
|
|
587
|
+
prose_path, wiring = APPLY[host](home, bullet, dry)
|
|
588
|
+
|
|
589
|
+
print(f"collect-learning: {'[dry] ' if dry else ''}OK host={host} "
|
|
590
|
+
f"learning_id={record['learning_id']} domain={record['domain']}")
|
|
591
|
+
print(f" prose -> {prose_path}")
|
|
592
|
+
print(f" record -> {jsonl}")
|
|
593
|
+
print(f" {wiring}")
|
|
594
|
+
|
|
595
|
+
if not dry and not args.no_upload:
|
|
596
|
+
print_upload_summary(drain_uploads(home))
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
if __name__ == "__main__":
|
|
600
|
+
main()
|
package/scripts/install.sh
CHANGED
|
@@ -412,6 +412,24 @@ remove_zsh_hook() {
|
|
|
412
412
|
info "removed zsh hook $ZSHRC"
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
# Promote -> migrate (collection loop, Phase 4): after the corpus is deployed,
|
|
416
|
+
# clear personal copies of learnings that have been promoted into the shared
|
|
417
|
+
# corpus AND are in this user's assembled bundle. Best-effort: a prune failure
|
|
418
|
+
# (or an absent manifest/script) never fails the install. Runs per host.
|
|
419
|
+
migrate_learnings() {
|
|
420
|
+
local script="$REPO/scripts/migrate-learnings.py"
|
|
421
|
+
{ [ -f "$script" ] && [ -f "$REPO/config/promotions.json" ]; } || return 0
|
|
422
|
+
local -a sel dry
|
|
423
|
+
if packaged_mode; then sel=(--selection-file "$STATE_DIR/selection.json"); else sel=(--full); fi
|
|
424
|
+
[ "$DRY_RUN" = 1 ] && dry=(--dry-run) || dry=()
|
|
425
|
+
# ${dry[@]+...}: expanding an empty array as "${dry[@]}" is an unbound-variable
|
|
426
|
+
# error under `set -u` on bash 3.2 (macOS default) and would abort the install.
|
|
427
|
+
python3 "$script" --host claude --config-dir "$CLAUDE_DIR" "${sel[@]}" ${dry[@]+"${dry[@]}"} \
|
|
428
|
+
|| info "learnings migrate (claude) skipped"
|
|
429
|
+
python3 "$script" --host codex --config-dir "$CODEX_DIR" "${sel[@]}" ${dry[@]+"${dry[@]}"} \
|
|
430
|
+
|| info "learnings migrate (codex) skipped"
|
|
431
|
+
}
|
|
432
|
+
|
|
415
433
|
# ---- subcommands ---------------------------------------------------------
|
|
416
434
|
cmd_install() {
|
|
417
435
|
check_prereqs || { log "resolve the prerequisites above and retry"; exit 1; }
|
|
@@ -433,6 +451,7 @@ cmd_install() {
|
|
|
433
451
|
deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
|
|
434
452
|
deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
|
|
435
453
|
fi
|
|
454
|
+
migrate_learnings # Phase 4: clear personal copies now absorbed by the corpus
|
|
436
455
|
deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
|
|
437
456
|
codex_config_additions merge || exit 1
|
|
438
457
|
deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
|