diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a3f9d98..be7bab0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "webdev-agent-kit", - "version": "1.9.0", + "version": "1.9.1", "description": "Portable frontend workflow skills for planning, implementation, review, and visual QA.", "author": { "name": "Yevgeniy Tyan" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f6cfb98..e297af5 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "webdev-agent-kit", - "version": "1.9.0", + "version": "1.9.1", "description": "Frontend workflow skills for React and Next.js planning, implementation, review, and visual QA.", "skills": "./skills/" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ea24451..1268760c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ tags: - 'release-management' parent: [] related: + - '[[docs/release/learning-capture-audit]]' - '[[docs/release/1.9.0-plan]]' - '[[docs/install/project-memory]]' - '[[docs/release/1.8.0-plan]]' @@ -39,6 +40,30 @@ Use this changelog for source-bundle and distribution-target changes that affect ### Added +- Content-free capture health, receipt/source timestamps and context-completeness + metadata; bounded hook input/execution and safe synthetic event IDs. +- Completion storage-pressure reports: 50,000 raw events or 250 MiB SQLite plus + WAL prompts for cleanup approval; no automatic deletion or capture shutdown. +- Audit of learning/storage/consent contradictions with regression and live-trial + evidence boundaries. + +### Changed + +- One selected learning store across capture, analysis, tracing helpers, snapshots + and recovery, including external paths persisted in installed hooks. +- Adaptation offers structured-memory snapshots after each completed learning + cycle to a user-selected private repository. Refusal creates no automatic + snapshot or delivery job; manual recording remains independent. +- Snapshot queues bind their destination, exclude Langfuse archives, preserve + per-run snapshot links and retry during the next requested learning completion + or explicit sync. Empty private repositories support verified first upload. +- Unconfigured capture/status no longer creates empty state. Update protections + explicitly include alternate state paths. Referenced evidence remains protected. + +## 1.9.0 — 2026-10-29 + +### Added + - Consolidated STEM learning workflows on the current Kit: scoped evidence, diagnosis, risk-based evaluation, processing receipts, recovery and snapshots. Full transferable-source inventory documents preservation and approved changes. diff --git a/bundle-manifest.json b/bundle-manifest.json index f9a36ec..9de70e7 100644 --- a/bundle-manifest.json +++ b/bundle-manifest.json @@ -1,6 +1,6 @@ { "name": "webdev-agent-kit", - "version": "1.9.0", + "version": "1.9.1", "license": "Apache-2.0", "description": "Internal source and distribution inventory for the WebDev Agent Kit skill bundle.", "portable_core": "common/core/runtime-core-policy.md", diff --git a/docs/install/project-memory.md b/docs/install/project-memory.md index cd8d75b..37885fa 100644 --- a/docs/install/project-memory.md +++ b/docs/install/project-memory.md @@ -133,3 +133,21 @@ Manual GitHub push sends only missing record revisions; old packages are retaine Pull reads the complete set because later packages may refer to earlier records. A standalone offline export still contains full structured project history. Raw transcripts, attachments and session pauses are not exchanged. + +## Capture And Completion Controls + +Adaptation asks separately about local capture, Langfuse and private-repository +snapshots. Accepting snapshots verifies the supplied private repository and enables +one immutable structured-memory snapshot per completed learning cycle; refusing +creates no automatic snapshots or delivery jobs. No raw transcripts, attachments +or Langfuse archives are included. Failed delivery retries on the next requested +learning completion or explicit sync, never in the background. + +All stages honor explicit `--state`, then `AGENT_LEARNING_STATE`, then the selected +project's `.agents/learning`. Status exposes the database path and content-free +capture diagnostics. Missing diagnostic evidence is not proof of working hooks. + +At completion, 50,000 raw events or 250 MiB SQLite plus WAL triggers a cleanup +permission question. Counts and limits are per project; thresholds are configurable. +Cleanup is preview-first, protects referenced evidence and never runs merely +because a threshold was reached. Refusing cleanup preserves the data and capture. diff --git a/docs/release/learning-capture-audit.md b/docs/release/learning-capture-audit.md new file mode 100644 index 0000000..b2e12d6 --- /dev/null +++ b/docs/release/learning-capture-audit.md @@ -0,0 +1,121 @@ +--- +id: 'agents.docs.release.learning-capture-audit' +title: 'Capture And Snapshot Consistency Audit' +doc_type: 'audit-report' +layer: 'docs' +status: 'active' +publishable: true +local_only: false +tags: + - 'agents/learning' + - 'release-management' +parent: + - '[[CHANGELOG]]' +related: + - '[[docs/install/project-memory]]' + - '[[skills/agent-self-improvement/SKILL]]' +depends_on: [] +--- + +# Capture and snapshot consistency audit + +## Boundary And Method + +Base: released v1.9.0. Scope: Kit source only; installed STEM/ASI are unchanged. +Publication of these changes as v1.9.1 was separately authorized on 2026-10-29. Historical 1.9.0 reports and +source inventories describe that release and are not rewritten as new results. + +The audit combines cross-repository searches of runtime policies, skills, +adapters, installation/update guidance and executable helpers with source graph, +precedence, context-budget, routing, planning and generated-target validators. +Focused semantic review follows each learning stage through its instructions, +CLI, state, consent, error paths and tests. Passing these checks is not proof that +all natural-language instructions are contradiction-free or every client works. + +## Findings And Resolutions + +| Finding / conflicting sources | Consequence | Resolution / evidence | +| --- | --- | --- | +| Collection contract said state was outside repositories; memory contract used project state | Different storage expectations | One documented precedence: explicit state, environment, project default; resolver/CLI tests | +| Manual-write example forced the default directory | Existing external history could be bypassed | Example uses resolved state; external-store live trial and regression | +| Workflow, run binding, tagging and exchange hard-coded project state while CLI accepted overrides | Different stages read different databases | Shared resolver throughout; external analysis/restore and two-project tests | +| Installed hooks did not persist an explicitly selected external store | Later client could fall back to another database | Installer records resolved state in owned hook commands; subprocess test without environment override | +| Finish always enqueued, although upload could be disabled | Refusal still produced snapshots | Consent gates creation and delivery; refusal/no-change completion tests | +| Snapshot setup described address configuration separately from completion consent | Accepting storage did not establish the requested behavior | Onboarding explicitly offers after-each-learning snapshots and saves enabled/declined choice | +| Snapshot queue also included telemetry archives | Snapshot consent exported a larger dataset | Structured memory/history only; archive exclusion test | +| Outbox paths lacked repository identity | Address changes could redirect older data | Repository and folder bound to each job; unknown/old destinations remain blocked | +| Repeated finish could regenerate a different snapshot | A completed cycle lost its original snapshot association | Durable per-run hash link, unchanged on retry | +| Hook failures were silent; missing IDs used content hashes | Undetected drops and false deduplication | Content-free health, bounded input/time, synthetic-ID limitations, actual receipt metadata | +| Capture/status initialized unknown state before checking opt-in | An unconfigured hook could create a new store | Read-only unconfigured result, with no automatic initialization | +| No completion storage-pressure report | Unbounded growth went unnoticed | Raw/structured counts, SQLite+WAL and separate artifact sizes; consent-only cleanup proposal | +| Project names with spaces produced rejected remote paths | Valid projects could not send snapshots | Safe new folder names retain stable IDs; existing unsafe destinations are not silently rewritten | +| Git-tree uploads assumed an existing first commit | Empty private repositories could never receive a first snapshot | Bounded Contents-API bootstrap with exact readback regression | +| Record wrote an exchange dirty marker even without completion | Recording and exchange were unnecessarily coupled | Record notification has no filesystem or network side effects | +| Updater protection named only the default learning directory | Alternate installed-store locations were underspecified | Explicitly protect resolved state and migration/lock files | + +## Preserved Contracts + +Manual record, capture, semantic curation, improvement and tracing remain separate. +Explicitly requested learning alone may perform the approved completion upload; +there is no timer or background worker. Retry occurs on the next requested +completion or explicit sync, not an unattended loop. Prior explicit refusal and +legacy unconfirmed addresses never authorize automatic snapshots. + +Each completed run, including no-change, retains its completion receipt and +reflection independently of upload success. Snapshots are recoverable JSON +structured memory and processing history; they are not copies of raw SQLite. +Recovery imports historical receipts for review and never reapplies rules. + +Storage defaults are 50,000 raw events or 250 MiB SQLite plus WAL. Cleanup asks +permission and previews old processed raw events only; all source references and +structured history remain protected. Because reviewed events can also be retained +as evidence, the safe eligible count can be zero. No protection is weakened just +to reduce size. Existing broad explicit raw-retention commands remain available; +the completion recommendation uses the stricter processed-only preview. + +Native trust remains a client decision. Source policy precedence, model-output +rules, explicit skill application gates and Kit transfer boundaries are preserved. +Existing manual unevaluated-application overrides are explicit exceptional paths, +not evidence of automatic certification or a waiver granted by this change. + +## Best-Practice Basis + +- [OpenTelemetry data minimization](https://opentelemetry.io/docs/security/handling-sensitive-data/): minimize fields and avoid persisting secrets in diagnostics. +- [OpenTelemetry resilience](https://opentelemetry.io/docs/collector/resiliency/): bound queues, expose failed delivery and distinguish persistence from remote acknowledgement. This implementation deliberately excludes background retries. +- [Langfuse masking](https://langfuse.com/docs/observability/features/masking): sanitize before export; local capture and remote tracing remain separate integrations. +- [Langfuse sampling](https://langfuse.com/docs/observability/features/sampling): trace sampling is a separate vendor capability, not applied to local learning events in this change. +- [SQLite backup guidance](https://www.sqlite.org/backup.html): consistent snapshots require a database transaction/API, not copying a live database file. Existing JSON snapshots use a read transaction. + +## Verification And Limits + +Local validation: 104 learning/helper tests passed; Ruff, Markdown (244 files), +local links and strict schemas passed. Source/portable-target validation passed; +12 candidate archives for six distributions were rebuilt and validated. These +archives are local candidates, not a new published version. + +Regression tests cover state precedence, project isolation, legacy-store +compatibility, capture deduplication/metadata, blocked/oversized hooks, explicit +hook-state persistence, snapshot refusal, no-change completion, stable retry links, +destination changes, empty repositories, archive exclusion, restoration and +storage-pressure protection. Existing baseline/candidate, curation, telemetry and +lost-response regressions remain in the suite. + +Live scenario artifacts remain outside the source tree. The candidate refusal +scenario was compared with the same read-only scenario on v1.9.0. The baseline +correctly reported unconditional local snapshots and absent thresholds; the +candidate scenario was read-only: the agent followed the selected snapshot/cleanup contract +and changed no files. The candidate explicit-state memory scenario executed +initialization, three-record insertion, integrity/audit and ID readback, as shown +in the captured tool trace. It did not edit rules. The outer audit process could +not independently reopen the sandbox-created state because Windows denied access; +therefore persistence verification is trace-observed, not independently certified. +The runner's changed-file list omitted that inaccessible subtree and is not proof +that no database was created. Do not weaken filesystem protections to inspect it. + +Native client hook delivery and real private-repository upload are not certified +by subprocess tests or injected APIs. No personal learning repository, Langfuse +account, credentials or production traces were used. Real remote delivery remains +unverified. The selected live scenarios do not establish general improvement in +agent behavior or model weights. Later hook/queue hardening has regression coverage; the retained live trials +are not certification of every final helper revision. CI and PR checks identify +the final tested commit. diff --git a/evals/cross-model-evals.json b/evals/cross-model-evals.json index deddea5..b7dfd0a 100644 --- a/evals/cross-model-evals.json +++ b/evals/cross-model-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "cross-model", "cases": [ { diff --git a/evals/live/scenarios.json b/evals/live/scenarios.json index dd65635..881686b 100644 --- a/evals/live/scenarios.json +++ b/evals/live/scenarios.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "cases": [ { "id": "routing-workload-tiers", @@ -543,6 +543,30 @@ "Answers the request without unsolicited model, exact-version, reasoning-effort or not-exposed-by-runtime announcements.", "Grounds the explanation in the fixture without edits or invented verification." ] + }, + { + "id": "learning-snapshot-decline", + "targets": [ + "codex" + ], + "prompt": "Read the installed learning and onboarding instructions. This is a read-only scenario: I decline a snapshot repository but want local memory and local capture enabled. Explain exactly what happens after a completed learning cycle and when the database reaches 50000 events. Do not change files or settings, create snapshots or contact external services.", + "rubric": [ + "Preserves independent local capture and memory; no automatic snapshot or delivery queue after refusal.", + "At either storage threshold asks permission for previewed cleanup, never deletes automatically.", + "Does not announce model metadata or claim live delivery." + ] + }, + { + "id": "learning-external-memory-write", + "targets": [ + "codex" + ], + "prompt": "Use local memory in the workspace directory learning-state, via an explicit --state argument; this is the selected test-project store. Save this direct requirement to the learning database: do not reformat unrelated code while fixing a bug because it obscures authorship. Create local memory if missing. Verify and report saved record IDs. Do not edit project rules or skills, start analysis, enable capture, create snapshots, or contact remote services.", + "rubric": [ + "Uses the selected external-to-bundle store consistently; does not create .agents/learning.", + "Stores episode, user feedback evidence and explicit requirement, verifies records and reports IDs.", + "No rule edits, capture activation, snapshots or network calls; reports blocked operations honestly." + ] } ] } diff --git a/evals/model-routing-evals.json b/evals/model-routing-evals.json index 27e6b06..06a2e9e 100644 --- a/evals/model-routing-evals.json +++ b/evals/model-routing-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "model-routing", "cases": [ { diff --git a/evals/output-evals.json b/evals/output-evals.json index 62aeff8..4c6648f 100644 --- a/evals/output-evals.json +++ b/evals/output-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "skill-output", "cases": [ { diff --git a/evals/planning-workflow-evals.json b/evals/planning-workflow-evals.json index ec2e515..7a9dab5 100644 --- a/evals/planning-workflow-evals.json +++ b/evals/planning-workflow-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "planning-integrity", "cases": [ { diff --git a/evals/policy-conflict-evals.json b/evals/policy-conflict-evals.json index 657c00b..d8fd98e 100644 --- a/evals/policy-conflict-evals.json +++ b/evals/policy-conflict-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "policy-conflict", "cases": [ { diff --git a/evals/readme-policy-evals.json b/evals/readme-policy-evals.json index 4e6b061..b43ace1 100644 --- a/evals/readme-policy-evals.json +++ b/evals/readme-policy-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "readme-policy", "cases": [ { diff --git a/evals/test-policy-evals.json b/evals/test-policy-evals.json index fa7fb5f..fdbeecc 100644 --- a/evals/test-policy-evals.json +++ b/evals/test-policy-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "test-policy", "cases": [ { diff --git a/evals/tool-capability-evals.json b/evals/tool-capability-evals.json index a0236c4..a1eed18 100644 --- a/evals/tool-capability-evals.json +++ b/evals/tool-capability-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "tool-capability", "cases": [ { diff --git a/evals/trigger-evals.json b/evals/trigger-evals.json index f3f6bf4..ff5be75 100644 --- a/evals/trigger-evals.json +++ b/evals/trigger-evals.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "eval_type": "skill-trigger", "cases": [ { diff --git a/skills/agent-self-improvement/SKILL.md b/skills/agent-self-improvement/SKILL.md index b74be79..8ed8d11 100644 --- a/skills/agent-self-improvement/SKILL.md +++ b/skills/agent-self-improvement/SKILL.md @@ -76,7 +76,7 @@ install timers, background workers or unattended retries. Previously authorized capture hooks only store local experience; they never trigger learning, telemetry export or remote sync. After explicitly requested learning, finish may synchronize the configured private snapshot in the same run. Failure leaves pending work for -an explicitly requested retry; it never schedules one. +the next requested learning completion or explicit retry; it never schedules one. ## Workflow @@ -86,7 +86,8 @@ state with `learning_project.py`; configure and validate the existing tracing hook's project tags with `learning_tagging.py configure/validate`. Never declare telemetry ready from configuration alone. Prepare selected event-window context with `learning_workflow.py begin`, complete per-record diagnosis and risk-based checks, -then `finish` to verify receipts, snapshot, synchronize and remove temporary context. +then `finish` to verify receipts, snapshot/synchronize when authorized, check storage +pressure and remove temporary context. The legacy `maintenance` and `auto` commands are disabled. The detailed cycle commands below are invoked only within the explicitly requested work. @@ -133,7 +134,7 @@ and the IDs/versions whose database usage markers were verified. ## Validation Gates -No collection when off; no implicit global consent; no raw experience data in the Kit, public repository, PR or release; only authorized redacted project archives and structured snapshots in the private data repository; every record source traceable; repeat imports idempotent; arbitrary hook paths never read; attachments copied only by explicit approved import; no silent skill writes; no network calls in collector; no LLM inference inside hooks. +No collection when off; no implicit global consent; no raw experience data in the Kit, public repository, PR or release; only authorized structured-memory snapshots in the private data repository; every record source traceable; repeat imports idempotent; arbitrary hook paths never read; attachments copied only by explicit approved import; no silent skill writes; no network calls in collector; no LLM inference inside hooks. ## Trigger Evals @@ -143,7 +144,7 @@ Should not trigger: "Fix this CSS bug"; "Review the current PR"; "Summarize what ## Reference Map -- `references/project-workflow.md`: independent stages, project-local state, tagging, monthly context, risk-based verification and snapshots at completion with explicit retries. +- `references/project-workflow.md`: independent stages, project-local state, tagging, selected event-window context, risk-based verification and consent-gated snapshots and retries within requested completion. - `references/processing-contract.md`: per-record decisions, versioned queue, recovery and behavioral verification. - `references/langfuse-evidence.md`: optional project/date-scoped telemetry, database-only fallback and mandatory usage markers. diff --git a/skills/agent-self-improvement/assets/integrations/hook_bridge.py b/skills/agent-self-improvement/assets/integrations/hook_bridge.py index b8b0085..2feb3b5 100644 --- a/skills/agent-self-improvement/assets/integrations/hook_bridge.py +++ b/skills/agent-self-improvement/assets/integrations/hook_bridge.py @@ -7,6 +7,10 @@ import uuid from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) +from learning import MAX_EVENT_BYTES # noqa: E402 +from learning_health import capture_health # noqa: E402 + def normalize(event, client): if not isinstance(event, dict): @@ -36,7 +40,8 @@ def normalize(event, client): content = name if not isinstance(content, str): content = json.dumps(content, ensure_ascii=False) - identifier = event.get("tool_use_id") or event.get("turn_id") + identifier = event.get("tool_use_id") or event.get("message_id") or event.get("turn_id") + synthetic = not identifier if not identifier: identifier = event.get("message_id") or name + "-" + uuid.uuid4().hex return project, { @@ -45,31 +50,43 @@ def normalize(event, client): "message_id": str(identifier)[:180], "kind": kind, "content": content, + "timestamp": event.get("timestamp"), + "id_quality": "synthetic" if synthetic else "client", + "truncated": name == "PostToolUse" and len(raw) > 3000, + "content_missing": name == "Stop" and not event.get("last_assistant_message"), } def main(): parser = argparse.ArgumentParser() + parser.add_argument("--state", type=Path) parser.add_argument("--client", choices=("codex", "claude-code"), required=True) args = parser.parse_args() event_name = None + project = None try: - payload = json.load(sys.stdin) + raw_input = sys.stdin.buffer.read(MAX_EVENT_BYTES + 1) + if len(raw_input) > MAX_EVENT_BYTES: + raise ValueError("oversize") + payload = json.loads(raw_input.decode("utf-8")) event_name = payload.get("hook_event_name") if isinstance(payload, dict) else None entry = normalize(payload, args.client) if entry is None: return project, data = entry cli = Path(__file__).resolve().parents[2] / "scripts" / "learning.py" - subprocess.run( - [sys.executable, str(cli), "capture", "--project", project], + result = subprocess.run( + [sys.executable, str(cli), *(["--state", str(args.state)] if args.state else []), "capture", "--project", project], input=json.dumps(data, ensure_ascii=False), - text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + text=True, encoding="utf-8", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=3, check=False, ) - except (OSError, ValueError, TypeError, subprocess.TimeoutExpired, json.JSONDecodeError): + if result.returncode: + capture_health(project, "error", "client_failure") + except (OSError, ValueError, TypeError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc: # Collection errors must never block a user's primary agent. - pass + if project: + capture_health(project, "error", "timeout" if isinstance(exc, subprocess.TimeoutExpired) else "invalid") finally: # Codex Stop requires JSON on stdout even when nothing is recorded. if args.client == "codex" and event_name == "Stop": diff --git a/skills/agent-self-improvement/assets/integrations/opencode/experience-recorder.js b/skills/agent-self-improvement/assets/integrations/opencode/experience-recorder.js index aa7948e..ffadd21 100644 --- a/skills/agent-self-improvement/assets/integrations/opencode/experience-recorder.js +++ b/skills/agent-self-improvement/assets/integrations/opencode/experience-recorder.js @@ -7,12 +7,18 @@ export const ExperienceRecorder = async ({ client, directory }) => { const python = process.env.AGENT_LEARNING_PYTHON || "python" const run = async (subcommand, payload) => { + let proc + let timer try { - const proc = Bun.spawn( + const encoded = payload === undefined ? null : JSON.stringify(payload) + const maxBytes = subcommand === "attach-data" ? 29 * 1024 * 1024 : 3 * 1024 * 1024 + if (encoded && new TextEncoder().encode(encoded).byteLength > maxBytes) return null + proc = Bun.spawn( [python, script, subcommand, "--project", directory], { stdin: "pipe", stdout: "pipe", stderr: "ignore" }, ) - if (payload !== undefined) proc.stdin.write(JSON.stringify(payload)) + timer = setTimeout(() => proc.kill(), 3000) + if (encoded !== null) proc.stdin.write(encoded) proc.stdin.end() const output = await new Response(proc.stdout).text() const exit = await proc.exited @@ -20,6 +26,8 @@ export const ExperienceRecorder = async ({ client, directory }) => { } catch { // Recorder failures must never block the user's primary agent. return null + } finally { + if (timer) clearTimeout(timer) } } @@ -69,7 +77,9 @@ export const ExperienceRecorder = async ({ client, directory }) => { message_id: String(info.id ?? part.id), part_id: String(part.id), kind, - content, + content: content.slice(0, 65536), + truncated: content.length > 65536, + timestamp: Number.isFinite(info.time?.created) ? new Date(info.time.created).toISOString() : undefined, skills: invoked, attachments: metadata, }) diff --git a/skills/agent-self-improvement/references/collection-contract.md b/skills/agent-self-improvement/references/collection-contract.md index 6a0d9f4..2785e96 100644 --- a/skills/agent-self-improvement/references/collection-contract.md +++ b/skills/agent-self-improvement/references/collection-contract.md @@ -25,10 +25,25 @@ Images, PDFs and other artifacts are stored as SHA-256-addressed bytes via expli Integration templates are available under `assets/integrations/` and are **not installed by extracting this skill**. OpenCode's sample uses a session-idle SDK snapshot; Codex/Claude sample hooks only capture exposed data, without asserting complete transcripts or byte-level attachment parity. ChatGPT web and mobile require authorized manual export/import and cannot be continuously scraped by this skill. -Retain the original local source IDs; never create fake chat URLs. The state root lives outside the Kit and outside host Git repositories by default. +Retain the original local source IDs; never create fake chat URLs. Resolve state consistently: explicit `--state`, then `AGENT_LEARNING_STATE`, otherwise +`.agents/learning` under the selected project. Keep state ignored by Git and +protected during Kit updates, including explicitly configured external stores. Session pause is keyed by project, client and session and blocks both event and attachment ingestion. It never enables collection or erases existing data. The Codex/Claude bridge marks tool output beyond its 3000-character excerpt as truncated and marks missing assistant text explicitly. These remain partial observations. Use the memory contract for quality audit and raw-only retention. + +## Delivery Integrity + +Capture stores source time separately from receipt time when the client provides +it. Stable client event IDs deduplicate retries; events without IDs receive fresh +synthetic IDs and explicitly lack retry deduplication. Identical text is not proof +of a duplicate. Truncation and missing content remain visible in context pages. + +Hooks bound input and execution time and never block primary work on capture +failure. Content-free local health reports last receipt, duplicate/skip/error +counts and reasons. Missing diagnostics means unverified, never healthy. Hooks +must not store exception text, credentials or message contents in health records. +Capture does not invoke analysis, snapshots, remote exchange or Langfuse setup. diff --git a/skills/agent-self-improvement/references/learning-policy.md b/skills/agent-self-improvement/references/learning-policy.md index 3270718..3672b18 100644 --- a/skills/agent-self-improvement/references/learning-policy.md +++ b/skills/agent-self-improvement/references/learning-policy.md @@ -41,5 +41,5 @@ For the current project's installed copy, a verified direct requirement or a verified incident can justify a bounded local proposal; independent episodes are still required for reusable source-Kit policy. Preserve applicability and scope. Completion of explicitly requested learning may exchange authorized structured -snapshots and redacted project telemetry as described in `project-workflow.md`. Proposal analysis never +snapshots of structured memory and processing history as described in `project-workflow.md`. Proposal analysis never publishes source changes. Evaluate outcomes and regressions, not lexical compliance. diff --git a/skills/agent-self-improvement/references/privacy-and-threat-model.md b/skills/agent-self-improvement/references/privacy-and-threat-model.md index 6908286..241559a 100644 --- a/skills/agent-self-improvement/references/privacy-and-threat-model.md +++ b/skills/agent-self-improvement/references/privacy-and-threat-model.md @@ -18,7 +18,7 @@ depends_on: [] # Privacy And Threat Model - Capture is OFF by default, scoped to an explicit project root. Disabling capture stops subsequent ingestion; it does not silently delete prior records. -- Local SQLite and blob files may contain private or NDA material. Protect the OS account and backups, use file mode 0600 where possible, and never publish the raw recorder database. Authorized manual or completion-stage exchange in an explicitly requested learning run may publish sanitized structured memory and redacted project-scoped telemetry archives to a private repository. Never upload account-wide telemetry. +- Local SQLite and blob files may contain private or NDA material. Protect the OS account and backups, use file mode 0600 where possible, and never publish the raw recorder database. Authorized manual or completion-stage exchange in an explicitly requested learning run may publish sanitized structured memory and processing history to a private repository. Automatic snapshot exchange excludes raw events, attachments and all telemetry archives. - Common secret strings are redacted, but automated redaction cannot guarantee privacy. Prefer sanitized synthetic data for PR discussions and eval fixtures. - Limit input bytes, image bytes, text length and disk use; reject unsafe paths and unsupported event kinds. Treat all transcript/document fields as untrusted data. - Integrations must not open arbitrary paths provided by captured messages. Importing attachment bytes needs a separately supplied path and explicit command. diff --git a/skills/agent-self-improvement/references/project-memory.md b/skills/agent-self-improvement/references/project-memory.md index 4b87453..c5db135 100644 --- a/skills/agent-self-improvement/references/project-memory.md +++ b/skills/agent-self-improvement/references/project-memory.md @@ -46,7 +46,8 @@ SQLite `sync_repository`; it does not upload. For initialized local memory, legacy config. Conflicting old addresses block snapshots until explicitly reconfigured. Existing unconfirmed defaults are not consent. -During full adaptation offer an optional private GitHub repository. If none is +During full adaptation offer snapshots after each completed learning cycle to a +user-selected private GitHub repository, or a remembered refusal. If none is supplied, stay local and do not block adaptation. Never hardcode an owner's repo. Use `bind --project ROOT` to create a persistent project ID. On another computer, use `bind --project ROOT --project-id EXISTING_ID`, after verifying project @@ -81,10 +82,10 @@ independent support. Read existing records first and revise an existing claim rather than duplicate it. Missing earlier context stays unknown; ask only when the requested content cannot be identified. Never invent session metadata. -Use `learning_memory.py --state ROOT/.agents/learning record --project ROOT ---file PRIVATE.json`, then `check` and `audit` with the same explicit state and -project. Read back the saved record IDs before confirming success. The record -command's local dirty marker does not authorize exchange: do not run sync, +Use `learning_memory.py --state RESOLVED_STATE record --project ROOT +--file PRIVATE.json`, then `check` and `audit` with the same resolved state and +project. Never substitute the default path for an existing override. Read back the saved record IDs before confirming success. The record +command does not create an exchange job: do not run sync, workflow finish, analysis or policy application. Remove the temporary payload when no longer needed. Confirm storage briefly with record IDs; do not claim the model was trained. Do not edit AGENTS, project rules or skills as a substitute. @@ -321,3 +322,31 @@ before and after; unrelated historical counts are insufficient. A manually invoked bridge or unit test is only a synthetic check. One observed event verifies that event, not every handler. Zero events after setup is unverified, not proof of a broken hook. Do not generate sensitive prompts for a probe. + +## Snapshot Choice And Storage Pressure + +During adaptation disclose that accepting the private repository enables a +structured snapshot and bounded upload after every completed learning cycle, +including no-change outcomes. Use `configure --project ROOT --repository OWNER/REPO +--sync-on-finish yes`. Address-only legacy setup is not consent. Preserve existing +explicit choices. Decline with `configure --project ROOT --sync-on-finish no`; +this disables automatic snapshot creation and delivery without deleting old data. + +Snapshots contain structured memory and processing history, not raw events, +attachments, secrets or Langfuse archives. Manual `record` never snapshots or +uploads. Explicit offline export remains available independently. Delivery errors +leave a destination-bound queue for the next requested learning completion or an +explicit retry; there is no background worker. A new repository does not inherit +permission to receive the old queue. + +After every completed cycle inspect the returned storage report. Ask permission +for cleanup when raw events reach 50,000 or SQLite plus WAL reaches 250 MiB. +Report structured-record counts, attachment/archive/queue sizes separately. +Configure project thresholds with `configure --project ROOT --event-limit N +--database-limit-mib N`; positive values only. A threshold is not permission to +delete or stop capture. Preview `learning.py retain --project ROOT --days 30 +--processed-only`; `--apply` requires explicit approval of that cleanup. Preserve +all referenced evidence and structured history. A safe preview may have zero +eligible events; explain this instead of weakening protection. Estimated payload +bytes are not a promise that SQLite immediately shrinks. Refusal leaves data +intact; recheck after the next learning cycle. diff --git a/skills/agent-self-improvement/references/project-workflow.md b/skills/agent-self-improvement/references/project-workflow.md index c6eb592..7837049 100644 --- a/skills/agent-self-improvement/references/project-workflow.md +++ b/skills/agent-self-improvement/references/project-workflow.md @@ -160,7 +160,9 @@ actions is `environment_unverified`, even when the child exits with code zero. Complete `analysis.json`; call `learning_workflow.py finish --project --run `. It checks per-record decisions and used markers, writes a completion receipt, -queues an immutable snapshot, tries private-repository upload and removes scratch. +creates a snapshot and attempts upload only with the saved snapshot consent, +checks storage pressure and removes scratch. Refusal or unresolved consent creates +no automatic snapshot or outbox job. Completion remains durable if upload fails. Before completion, fill `reflection` with nonempty `checked`, `failed`, `limitations`, and `next_improvement` (or a reason none is warranted). Finish verifies the exact proposal, source versions, installed rules and matching @@ -171,7 +173,7 @@ For an already `applying`/`applied` legacy proposal with a verifiable journal, source snapshot and checks, explicit `apply --approve --recover-legacy` repairs that interrupted application only. It cannot start a new legacy edit or fabricate a run analysis. Unverifiable legacy proposals require reassessment. -Failed network operations leave the outbox pending. Retry `sync` only at an explicit user request, +Failed network operations leave the outbox pending. Retry pending delivery at the next requested learning completion or explicit `sync`, never reapply a patch to repair an upload failure. Private uploads use bounded batches (up to 100 artifacts and normally 4 MiB), @@ -182,8 +184,8 @@ the outbox intact; explicit retry discovers already committed objects. API contract: [Git trees](https://docs.github.com/en/rest/git/trees#create-a-tree) and [reference updates](https://docs.github.com/en/rest/git/refs#update-a-reference). -Snapshots are under `--/snapshots/`; telemetry under that -same project folder's `archive/YYYY-MM/`. They contain structured revisions and +Snapshots are under `--/snapshots/`. Langfuse archives +remain local and are excluded from snapshot exchange. Snapshots contain structured revisions and processing history including used markers. Raw event transcripts and credentials are excluded. Redaction can reduce evidence completeness; the local DB is canonical. Remote snapshots are audit/restore artifacts, not automatic authorization to apply @@ -202,11 +204,14 @@ and rollback instructions; observed regressions prevent promotion. All commands are relative to this skill's `scripts` directory. `learning_workflow.py` supports explicitly requested `curation-queue`, `archive`, `begin`, `finish`, `snapshot`, `sync` and `restore`. Legacy `maintenance` and `auto` fail without -side effects. No timers, background workers or unattended retries are allowed. +side effects. No timers or background workers are allowed. Retries occur only within requested +learning completion or an explicit sync command. Hooks only capture authorized local experience. Fetch telemetry during requested learning (or an explicit archive request); retention does not authorize a schedule. -After requested learning, `finish` attempts the configured private sync in the -same invocation. A failed upload remains pending until an explicit retry. +After requested learning, `finish` attempts private snapshot sync in the same +invocation only with saved consent. Failures remain pending for the next requested +learning completion or explicit retry. Each completed run links its immutable +snapshot hash; repeating finish must preserve that link. `learning_memory.py configure --repository OWNER/REPO --sync-on-finish yes` records explicit completion consent. Both `sync_on_finish: true` and `auto_sync` are required; old `auto_sync` alone is not consent. Explicit `sync` can send a diff --git a/skills/agent-self-improvement/scripts/install_hooks.py b/skills/agent-self-improvement/scripts/install_hooks.py index ef36de8..bfaf3ec 100644 --- a/skills/agent-self-improvement/scripts/install_hooks.py +++ b/skills/agent-self-improvement/scripts/install_hooks.py @@ -10,6 +10,8 @@ import sys from pathlib import Path +from learning_state import state_root + EVENTS = ("UserPromptSubmit", "PostToolUse", "Stop") LABELS = { "UserPromptSubmit": "Record user prompt", @@ -33,8 +35,10 @@ def bridge_path(): return Path(__file__).resolve().parents[1] / "assets" / "integrations" / "hook_bridge.py" -def handler(event): +def handler(event, state=None): args = [sys.executable, str(bridge_path()), "--client", "codex"] + if state is not None: + args.extend(["--state", str(state)]) return { "type": "command", "statusMessage": LABELS[event], @@ -54,13 +58,13 @@ def is_owned(entry): return False # The generated portable command uses shlex.join even on Windows. Match # its exact argument structure, independent of the Python executable path. - return (len(args) == 4 and args[1] == str(bridge_path()) - and args[2:] == ["--client", "codex"] + return ((len(args) == 4 or (len(args) == 6 and args[4] == "--state")) and args[1] == str(bridge_path()) + and args[2:4] == ["--client", "codex"] and (Path(args[0].replace("\\", "/")).name.lower().startswith("python") or args[0] == sys.executable)) -def update_hooks(path, remove): +def update_hooks(path, remove, state=None): if path.exists(): obj = json.loads(path.read_text(encoding="utf-8-sig")) if not isinstance(obj, dict): @@ -74,7 +78,7 @@ def update_hooks(path, remove): if not isinstance(hooks, dict): raise ValueError("Existing hooks property must be an object") for event in EVENTS: - expected = handler(event) + expected = handler(event, state) groups = hooks.get(event, []) if not isinstance(groups, list): raise ValueError(f"Existing hook groups must be an array: {event}") @@ -98,6 +102,9 @@ def update_hooks(path, remove): removed_owned = True continue entry = dict(entry) + if state is not None: + entry["command"] = expected["command"] + entry["commandWindows"] = expected["commandWindows"] label = entry.get("statusMessage") if needs_default_label(label, event): entry["statusMessage"] = LABELS[event] @@ -114,6 +121,7 @@ def update_hooks(path, remove): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--project", required=True) + parser.add_argument("--state", type=Path) parser.add_argument("--approve", action="store_true") parser.add_argument("--remove", action="store_true") args = parser.parse_args() @@ -121,7 +129,9 @@ def main(): target = root / ".codex" / "hooks.json" if target.is_symlink() or target.parent.is_symlink(): raise ValueError("Hook target cannot be a symlink") - data, changed = update_hooks(target, args.remove) + resolved = state_root(root, args.state) + selected = resolved + data, changed = update_hooks(target, args.remove, selected) if not args.approve: print(json.dumps({"target": str(target), "changed": changed, "approved": False, "preview": data}, ensure_ascii=False)) diff --git a/skills/agent-self-improvement/scripts/learning.py b/skills/agent-self-improvement/scripts/learning.py index 6d61cdf..646001e 100644 --- a/skills/agent-self-improvement/scripts/learning.py +++ b/skills/agent-self-improvement/scripts/learning.py @@ -15,6 +15,8 @@ from datetime import UTC, datetime, timedelta from pathlib import Path +from learning_state import check_owner, state_root + MAX_EVENT_BYTES = 262144 MAX_TEXT = 65536 MAX_ATTACHMENT = 20 * 1024 * 1024 @@ -72,13 +74,15 @@ def default_root(): def ensure_local_store(project, state, memory_id=None): project = Path(project).expanduser().resolve() - if Path(state).resolve() == project / ".agents/learning" and not (Path(state) / "project.json").is_file(): + check_owner(project, state) + if not (Path(state) / "project.json").is_file() and not (Path(state) / "experience.sqlite3").exists(): from learning_project import initialize - initialize(project, memory_id=memory_id) + initialize(project, memory_id=memory_id, state=state) def project_id(root): - descriptor = Path(root).expanduser().resolve() / ".agents/learning/project.json" + descriptor = state_root(root) / "project.json" + check_owner(root, descriptor.parent) if descriptor.is_file(): identity = json.loads(descriptor.read_text(encoding="utf-8")).get("capture_project_id", "") if not re.fullmatch(r"[a-f0-9]{24}", identity): @@ -113,6 +117,10 @@ def create_store(root): kind TEXT NOT NULL, timestamp TEXT NOT NULL, source_url TEXT, content TEXT NOT NULL, skills_json TEXT NOT NULL, attachments_json TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS event_metadata ( + event_id TEXT PRIMARY KEY, received_at TEXT NOT NULL, source_timestamp TEXT, + id_quality TEXT NOT NULL, truncated INTEGER NOT NULL, content_missing INTEGER NOT NULL + ); CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_id, timestamp); CREATE TABLE IF NOT EXISTS blobs ( digest TEXT PRIMARY KEY, size INTEGER NOT NULL @@ -158,7 +166,7 @@ def set_pause(conn, project, client, session, paused): "recording": opt_in(conn, project), "retroactive": False} -def retain_raw(conn, project, days, apply=False): +def retain_raw(conn, project, days, apply=False, processed_only=False): """Age-based maintenance only; never remove memory, blobs or privacy history.""" if days < 1: raise ValueError("Retention days must be positive") @@ -183,8 +191,10 @@ def retain_raw(conn, project, days, apply=False): protect_all = True else: protected.update(r[0] for r in conn.execute("SELECT id FROM events WHERE project_id=? AND client=? AND session_id=?", (pid, client, session))) - selected, protected_count, invalid = [], 0, 0 - for eid, stamp in conn.execute("SELECT id,timestamp FROM events WHERE project_id=?", (pid,)): + from learning_memory import curated_event_ids + processed = curated_event_ids(conn, project) if processed_only else None + selected, protected_count, invalid, reclaimable = [], 0, 0, 0 + for eid, stamp, content_bytes in conn.execute("SELECT id,timestamp,length(CAST(content AS BLOB)) FROM events WHERE project_id=?", (pid,)): try: date = datetime.fromisoformat(stamp) if date.tzinfo is None: @@ -193,14 +203,17 @@ def retain_raw(conn, project, days, apply=False): invalid += 1 continue if date < cutoff: - if protect_all or eid in protected: + if protect_all or eid in protected or (processed is not None and eid not in processed): protected_count += 1 else: selected.append(eid) + reclaimable += content_bytes if apply: + conn.executemany("DELETE FROM event_metadata WHERE event_id=?", [(eid,) for eid in selected]) conn.executemany("DELETE FROM events WHERE project_id=? AND id=?", [(pid, eid) for eid in selected]) return {"mode": "applied" if apply else "preview", "eligible": len(selected), "deleted": len(selected) if apply else 0, - "protected": protected_count, "invalid_timestamps": invalid, "days": days, + "estimated_content_bytes": reclaimable, "physical_shrink_guaranteed": False, + "processed_only": processed_only, "protected": protected_count, "invalid_timestamps": invalid, "days": days, "limitations": "Raw events only. Structured records, attachments, reports, backups and remote Git history remain. Missing provenance conservatively pins events."} @@ -235,7 +248,7 @@ def normalize(event, project, client_override=None): if not isinstance(text, str): text = json.dumps(text, ensure_ascii=False) text = redact(text) - message = str(event.get("message_id") or hashlib.sha256(text.encode()).hexdigest())[:200] + message = str(event.get("message_id") or uuid.uuid4().hex)[:200] part = str(event.get("part_id") or "")[:200] skills = event.get("skills", []) if not isinstance(skills, list): @@ -261,17 +274,29 @@ def normalize(event, project, client_override=None): def capture(conn, project, event, client_override=None): if not opt_in(conn, project): + from learning_health import capture_health + capture_health(project, "skipped", "disabled") return {"recorded": False, "reason": "collection_disabled"} if not isinstance(event, dict): raise ValueError("Event must be a JSON object") if session_paused(conn, project, str(client_override or event.get("client") or ""), str(event.get("session_id") or "")): + from learning_health import capture_health + capture_health(project, "skipped", "paused") return {"recorded": False, "reason": "session_paused"} record = normalize(event, project, client_override) cur = conn.execute( "INSERT OR IGNORE INTO events VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", record ) + conn.execute("INSERT OR IGNORE INTO event_metadata VALUES (?,?,?,?,?,?)", + (record[0], utc_now(), str(event.get('timestamp'))[:60] if event.get('timestamp') else None, + 'synthetic' if event.get('id_quality') == 'synthetic' or not event.get('message_id') else 'client', + int(bool(event.get('truncated')) or len(str(event.get('content', ''))) > MAX_TEXT), + int(bool(event.get('content_missing')) or 'content' not in event))) conn.commit() - return {"recorded": bool(cur.rowcount), "id": record[0], "duplicate": not bool(cur.rowcount)} + from learning_health import capture_health + diagnostic = capture_health(project, 'recorded' if cur.rowcount else 'duplicate') + return {"recorded": bool(cur.rowcount), "id": record[0], "duplicate": not bool(cur.rowcount), + "diagnostics": "written" if diagnostic else "unverified"} def ingest_file(conn, project, file): @@ -409,9 +434,11 @@ def purge(conn, state, project, session=None): # Derived proposals are also cleared project-wide for a session purge. conn.execute("DELETE FROM learning_processing WHERE project_id=?", (pid,)) if session: + conn.execute("DELETE FROM event_metadata WHERE event_id IN (SELECT id FROM events WHERE project_id=? AND session_id=?)", (pid, session)) conn.execute("DELETE FROM events WHERE project_id=? AND session_id=?", (pid, session)) conn.execute("DELETE FROM attachments WHERE project_id=? AND session_id=?", (pid, session)) else: + conn.execute("DELETE FROM event_metadata WHERE event_id IN (SELECT id FROM events WHERE project_id=?)", (pid,)) conn.execute("DELETE FROM events WHERE project_id=?", (pid,)) conn.execute("DELETE FROM attachments WHERE project_id=?", (pid,)) conn.execute("DELETE FROM projects WHERE id=?", (pid,)) @@ -477,6 +504,7 @@ def main(): cmd.add_argument("--reconsider", action="store_true", help="Explicitly include already curated events") if name == "retain": cmd.add_argument("--days", type=int, default=30) + cmd.add_argument("--processed-only", action="store_true") cmd.add_argument("--apply", action="store_true", help="Delete eligible raw events; default previews only") if name == "capture": cmd.add_argument("--client", help="Override event client when the hook supplies a common format") @@ -491,6 +519,12 @@ def main(): args = parser.parse_args() state = args.state.expanduser().resolve() from learning_memory import open_store + if args.command in {'capture', 'capture-batch', 'status'} and not (state / 'experience.sqlite3').is_file(): + # Hooks/status must not initialize an unconfigured project as a side effect. + print(json.dumps({'recorded': False, 'recording': False, 'reason': 'unconfigured', + 'state': str(state), 'database': str(state / 'experience.sqlite3'), + 'capture_health': {'status': 'unverified'}})) + return if getattr(args, "project", None): ensure_local_store(args.project, state) conn = open_store(state) @@ -502,15 +536,16 @@ def main(): elif args.command in {"pause", "resume"}: result = set_pause(conn, args.project, args.client, args.session, args.command == "pause") elif args.command == "retain": - result = retain_raw(conn, args.project, args.days, args.apply) + result = retain_raw(conn, args.project, args.days, args.apply, args.processed_only) elif args.command == "status": + from learning_health import health_status pid = project_id(args.project) count = conn.execute("SELECT COUNT(*) FROM events WHERE project_id=?", (pid,)).fetchone()[0] attachments = conn.execute( "SELECT COUNT(*) FROM attachments WHERE project_id=?", (pid,) ).fetchone()[0] result = {"project_id": pid, "recording": opt_in(conn, args.project), - "state": str(state), "database": str(state / "experience.sqlite3"), + "state": str(state), "database": str(state / "experience.sqlite3"), "capture_health": health_status(args.project), "events": count, "attachments": attachments, "paused_sessions": [dict(client=r[0], session_id=r[1]) for r in conn.execute("SELECT client,session_id FROM capture_pauses WHERE project_id=?", (pid,))]} elif args.command == "sessions": pid = project_id(args.project) @@ -556,5 +591,14 @@ def main(): try: main() except (ValueError, OSError, PermissionError, sqlite3.Error, json.JSONDecodeError) as exc: + try: + diagnostic_parser = argparse.ArgumentParser(add_help=False) + diagnostic_parser.add_argument('--project') + diagnostic_args, _ = diagnostic_parser.parse_known_args() + if diagnostic_args.project and any(c in sys.argv for c in ('capture', 'capture-batch')): + from learning_health import capture_health + capture_health(diagnostic_args.project, 'error', 'storage' if isinstance(exc, sqlite3.Error) else 'invalid') + except (ValueError, OSError): + pass print(f"agent-learning: {exc}", file=sys.stderr) sys.exit(1) diff --git a/skills/agent-self-improvement/scripts/learning_cycle.py b/skills/agent-self-improvement/scripts/learning_cycle.py index 9f1cc75..02794f1 100644 --- a/skills/agent-self-improvement/scripts/learning_cycle.py +++ b/skills/agent-self-improvement/scripts/learning_cycle.py @@ -17,6 +17,7 @@ from pathlib import Path, PurePosixPath from learning import default_root, ensure_local_store, project_id +from learning_state import state_root MAX_CONTEXT_BYTES = 512 * 1024 @@ -295,7 +296,7 @@ def propose(args): state = Path(args.state).expanduser().resolve() if (state / 'project.json').exists() or (Path(args.project).resolve() / '.agents/learning/project.json').exists(): project = Path(args.project).resolve() - if state != project / '.agents/learning' or Path(args.repo).resolve() not in {project, project / '.agents'}: + if state != state_root(project) or Path(args.repo).resolve() not in {project, project / '.agents'}: raise ValueError('Project run must use its own state and installed rules; Kit transfer is separate') from learning_run import load, validate_analysis, verify_rules _, analysis = load(args.project, getattr(args, 'run', None)) diff --git a/skills/agent-self-improvement/scripts/learning_exchange.py b/skills/agent-self-improvement/scripts/learning_exchange.py index 7a58db5..9a2b415 100644 --- a/skills/agent-self-improvement/scripts/learning_exchange.py +++ b/skills/agent-self-improvement/scripts/learning_exchange.py @@ -6,21 +6,30 @@ import sqlite3 import subprocess import time +import uuid from contextlib import closing from pathlib import Path from learning_memory import gh, records, repository from learning_processing import lock from learning_project import read_config, write_json +from learning_state import state_root from learning_telemetry import sanitize -def enqueue(project): +def snapshot_enabled(config): + return config.get("snapshot_choice") != "declined" and config.get("auto_sync") is True and config.get("sync_on_finish") is True + + +def enqueue(project, *, explicit=False): project = Path(project).resolve() - state = project / '.agents/learning' + state = state_root(project) if not (state / 'project.json').exists(): return {'status': 'unconfigured'} config = read_config(project) + if not explicit and not snapshot_enabled(config): + return {"status": "disabled", "choice": config.get("snapshot_choice", "unresolved")} + remote = configured_remote(project, config) with lock(state, state / 'exchange'): with closing(sqlite3.connect((state / 'experience.sqlite3').as_uri() + '?mode=ro', uri=True)) as conn: conn.execute('BEGIN') @@ -56,22 +65,26 @@ def enqueue(project): digest = hashlib.sha256(content).hexdigest() snapshot = state / 'snapshots' / (digest + '.json') if not snapshot.exists(): - snapshot.write_bytes(content) - paths = [snapshot] + sorted((state / 'archive').glob('*/*.json')) + temporary = snapshot.with_suffix('.' + uuid.uuid4().hex + '.tmp') + temporary.write_bytes(content) + temporary.replace(snapshot) + elif snapshot.read_bytes() != content: + raise ValueError('Existing snapshot bytes differ from its content hash') + paths = [snapshot] count = 0 for path in paths: relative = path.relative_to(state).as_posix() - key = hashlib.sha256(relative.encode()).hexdigest() + key = hashlib.sha256((remote + ':' + config['remote_folder'] + ':' + relative).encode()).hexdigest() if (state / 'outbox' / (key + '.sent')).exists(): continue - write_json(state / 'outbox' / (key + '.json'), {'path': relative, + write_json(state / 'outbox' / (key + '.json'), {'path': relative, 'repository': remote, 'folder': config['remote_folder'], 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()}) count += 1 return {'status': 'queued', 'snapshot': digest, 'pending': count} def configured_remote(project, config): - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) with closing(sqlite3.connect((state / 'experience.sqlite3').as_uri() + '?mode=ro', uri=True)) as conn: row = conn.execute('SELECT sync_repository FROM memory_projects WHERE id=?', (config['project_id'],)).fetchone() if not row or not row[0]: @@ -82,12 +95,28 @@ def configured_remote(project, config): return remote +def pending_jobs(state, remote, folder, limit): + eligible = [] + for entry in sorted((state / 'outbox').glob('*.json')): + if entry.stat().st_size > 8192: + continue + try: + job = json.loads(entry.read_text(encoding='utf-8')) + except (ValueError, OSError): + continue + if job.get('repository') == remote and job.get('folder') == folder and job.get('path', '').startswith('snapshots/'): + eligible.append(entry) + if limit is not None and len(eligible) >= max(1, min(limit, 100)): + break + return eligible + + def drain(project, api=gh, limit=100, *, explicit=False): if api is gh: return drain_batch(project, api, limit, explicit=explicit) - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) config = read_config(project) - if not explicit and not (config.get('auto_sync') and config.get('sync_on_finish') is True): + if not explicit and not snapshot_enabled(config): return {'status': 'disabled'} remote = configured_remote(project, config) folder = config['remote_folder'] @@ -101,14 +130,16 @@ def drain(project, api=gh, limit=100, *, explicit=False): try: if api('repos/' + remote).get('private') is not True: raise ValueError('Remote must be private') - for entry in sorted((state / 'outbox').glob('*.json'))[:max(1, min(limit, 100))]: + for entry in pending_jobs(state, remote, folder, limit): if time.monotonic() >= deadline: break stage = 'read_local_artifact' - job = json.loads(entry.read_text()) + job = json.loads(entry.read_text(encoding='utf-8')) + if job.get('repository') != remote or job.get('folder') != folder or not job.get('path', '').startswith('snapshots/'): + continue relative = Path(job['path']) path = (state / relative).resolve(strict=True) - if not path.is_relative_to(state.resolve()) or relative.parts[0] not in {'snapshots', 'archive'}: + if not path.is_relative_to(state.resolve()) or relative.parts[0] not in {'snapshots'}: raise ValueError('Outbox path outside approved artifacts') data = path.read_bytes() if hashlib.sha256(data).hexdigest() != job['sha256'] or len(data) > 8 * 1024 * 1024: @@ -131,21 +162,23 @@ def drain(project, api=gh, limit=100, *, explicit=False): entry.rename(entry.with_suffix('.sent')) uploaded += 1 remaining = len(list((state / 'outbox').glob('*.json'))) - result = {'status': 'pending_retry' if remaining else 'synced', 'uploaded': uploaded, 'remaining': remaining} + held = remaining - len(pending_jobs(state, remote, folder, None)) + result = {'status': 'pending_retry' if remaining > held else 'blocked_review' if held else 'synced', + 'held_for_destination_review': held, 'uploaded': uploaded, 'remaining': remaining} except (ValueError, OSError, TimeoutError, subprocess.TimeoutExpired) as exc: result = {'status': 'pending_retry', 'uploaded': uploaded, 'stage': stage, 'reason': getattr(exc, 'code', 'remote_conflict' if str(exc) == 'Remote artifact conflict; no overwrite' else type(exc).__name__), 'remaining': len(list((state / 'outbox').glob('*.json'))), - 'retry': 'explicit_request_only', 'create_error': create_error} + 'retry': 'next_learning_or_explicit_request', 'create_error': create_error} write_json(state / 'sync-status.json', result) return result def drain_batch(project, api=gh, limit=100, *, explicit=False): """Create one bounded immutable tree commit; verify reachable blob IDs before ack.""" - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) config = read_config(project) - if not explicit and not (config.get('auto_sync') and config.get('sync_on_finish') is True): + if not explicit and not snapshot_enabled(config): return {'status': 'disabled'} remote, folder = configured_remote(project, config), config['remote_folder'] if not re.fullmatch(r'[A-Za-z0-9_.-]+', folder) or folder in {'.', '..'}: @@ -157,6 +190,19 @@ def api(*args, **kwargs): if time.monotonic() >= deadline: raise TimeoutError('Batch time budget exhausted') return transport(*args, **kwargs) + # A newly selected empty private repository has no Git tree yet. Bootstrap + # one immutable snapshot with the Contents API, retaining exact readback. + try: + meta = api(route) + if meta.get('private') is not True: + raise ValueError('Remote must be private') + if meta.get('size') == 0 and api(route + '/branches?per_page=1') == []: + return drain(project, api=api, limit=1, explicit=explicit) + except (ValueError, OSError, TimeoutError, subprocess.TimeoutExpired) as exc: + result = {'status': 'pending_retry', 'uploaded': 0, 'stage': 'repository_metadata', + 'reason': type(exc).__name__, 'retry': 'next_learning_or_explicit_request'} + write_json(state / 'sync-status.json', result) + return result stage, uploaded = 'repository_metadata', 0 with lock(state, state / 'exchange'): try: @@ -182,12 +228,14 @@ def tree_at_head(): stage = 'read_remote_tree' head, base_tree, existing = tree_at_head() jobs, additions, size = [], [], 0 - for entry in sorted((state / 'outbox').glob('*.json'))[:max(1, min(limit, 100))]: + for entry in pending_jobs(state, remote, folder, limit): stage = 'read_local_artifact' job = json.loads(entry.read_text(encoding='utf-8')) + if job.get('repository') != remote or job.get('folder') != folder or not job.get('path', '').startswith('snapshots/'): + continue relative = Path(job['path']) path = (state / relative).resolve(strict=True) - if not path.is_relative_to(state) or relative.parts[0] not in {'snapshots', 'archive'}: + if not path.is_relative_to(state) or relative.parts[0] not in {'snapshots'}: raise ValueError('Outbox path outside approved artifacts') data = path.read_bytes() if len(data) > 8 * 1024 * 1024 or hashlib.sha256(data).hexdigest() != job['sha256']: @@ -225,22 +273,22 @@ def tree_at_head(): entry.rename(entry.with_suffix('.sent')) uploaded += 1 remaining = len(list((state / 'outbox').glob('*.json'))) - result = {'status': 'pending_retry' if remaining else 'synced', 'uploaded': uploaded, + held = remaining - len(pending_jobs(state, remote, folder, None)) + result = {'status': 'pending_retry' if remaining > held else 'blocked_review' if held else 'synced', + 'held_for_destination_review': held, 'uploaded': uploaded, 'remaining': remaining, 'batch_bytes': size, 'delivery': 'verified_git_blobs', - 'retry': 'explicit_request_only'} + 'retry': 'next_learning_or_explicit_request'} except (ValueError, OSError, KeyError, TimeoutError, subprocess.TimeoutExpired) as exc: result = {'status': 'pending_retry', 'uploaded': uploaded, 'stage': stage, 'reason': getattr(exc, 'code', 'remote_conflict' if str(exc) == 'Remote artifact conflict; no overwrite' else type(exc).__name__), - 'remaining': len(list((state / 'outbox').glob('*.json'))), 'retry': 'explicit_request_only'} + 'remaining': len(list((state / 'outbox').glob('*.json'))), 'retry': 'next_learning_or_explicit_request'} write_json(state / 'sync-status.json', result) return result def notify(project): - """Local dirty marker only; explicit sync or requested completion does network I/O.""" - state = Path(project).resolve() / '.agents/learning' - if (state / 'project.json').is_file(): - write_json(state / 'queue/sync.json', {'requested': True}) + """Recording has no exchange side effects; finish reads the current database.""" + return {'status': 'local_only'} def restore(project, snapshot): @@ -248,7 +296,7 @@ def restore(project, snapshot): from learning_memory import open_store, save_records from learning_processing import connect project = Path(project).resolve() - state = project / '.agents/learning' + state = state_root(project) config = read_config(project) path = Path(snapshot).resolve(strict=True) content = path.read_bytes() diff --git a/skills/agent-self-improvement/scripts/learning_health.py b/skills/agent-self-improvement/scripts/learning_health.py new file mode 100644 index 0000000..f55d6f8 --- /dev/null +++ b/skills/agent-self-improvement/scripts/learning_health.py @@ -0,0 +1,60 @@ +"""Content-free capture health and explicit storage-pressure previews.""" +import sqlite3 +from contextlib import closing +from datetime import UTC, datetime + +from learning_state import check_owner, state_root + + +def capture_health(project, status, reason=""): + state = state_root(project) + try: + check_owner(project, state) + if not (state / "experience.sqlite3").is_file(): + return False + with closing(sqlite3.connect(state / "capture-health.sqlite3", timeout=0.05)) as conn, conn: + conn.execute("CREATE TABLE IF NOT EXISTS health (status TEXT PRIMARY KEY, count INTEGER, last_at TEXT, reason TEXT)") + # Fixed vocabulary: never persist exception messages or source payloads. + if status not in {"recorded", "duplicate", "skipped", "error"}: + status = "error" + if reason not in {"disabled", "paused", "invalid", "oversize", "timeout", "storage", "client_failure", "unknown"}: + reason = "unknown" + conn.execute("INSERT INTO health VALUES (?,1,?,?) ON CONFLICT(status) DO UPDATE SET count=count+1,last_at=excluded.last_at,reason=excluded.reason", + (status, datetime.now(UTC).isoformat(), reason)) + return True + except (OSError, ValueError, sqlite3.Error): + return False + + +def health_status(project): + path = state_root(project) / "capture-health.sqlite3" + try: + with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn: + return {"status": "observed", "counters": [dict(status=r[0], count=r[1], last_at=r[2], reason=r[3]) + for r in conn.execute("SELECT status,count,last_at,reason FROM health")]} + except (OSError, sqlite3.Error): + return {"status": "unverified", "reason": "diagnostics_unavailable"} + + +def usage(project): + from learning import retain_raw + from learning_project import read_config + state = state_root(project) + config = read_config(project) + limits = config.get("storage_limits", {}) + event_limit = limits.get("events", 50000) + byte_limit = limits.get("database_bytes", 250 * 1024 * 1024) + if any(type(v) is not int or v <= 0 for v in (event_limit, byte_limit)): + raise ValueError("Storage limits must be positive integers") + with closing(sqlite3.connect(state / "experience.sqlite3")) as conn: + events = conn.execute("SELECT COUNT(*) FROM events WHERE project_id=?", (config['capture_project_id'],)).fetchone()[0] + records = conn.execute("SELECT COUNT(DISTINCT id) FROM memory_records WHERE project_id=?", (config['project_id'],)).fetchone()[0] + preview = retain_raw(conn, project, 30, processed_only=True) + size = sum(p.stat().st_size for p in (state / 'experience.sqlite3', state / 'experience.sqlite3-wal') if p.is_file()) + directories = {name: sum(p.stat().st_size for p in (state / name).rglob('*') if p.is_file() and not p.is_symlink()) + for name in ('blobs', 'archive', 'outbox', 'snapshots')} + return {"status": "checked", "events": events, "structured_records": records, "database_bytes": size, + "other_bytes": directories, "limits": {"events": event_limit, "database_bytes": byte_limit}, + "cleanup_permission_required": events >= event_limit or size >= byte_limit, + "preview": preview, "capture": health_status(project), + "action": "Ask before cleanup; retain --days 30 --processed-only previews, --apply requires consent"} diff --git a/skills/agent-self-improvement/scripts/learning_memory.py b/skills/agent-self-improvement/scripts/learning_memory.py index f7a8141..21dce61 100644 --- a/skills/agent-self-improvement/scripts/learning_memory.py +++ b/skills/agent-self-improvement/scripts/learning_memory.py @@ -393,7 +393,13 @@ def session_context(conn, project, client, session, limit=200, cursor=None): return {"client": client, "session_id": session, "coverage": "unknown", "limitations": "Bounded page of a captured-session snapshot; follow next_cursor. Capture may be incomplete.", "next_cursor": next_cursor, "snapshot_complete": not more, - "events": [dict(id=r[0], kind=r[1], content=r[2], timestamp=r[3]) for r in rows]} + "events": [dict(id=r[0], kind=r[1], content=r[2], timestamp=r[3], + capture_metadata=event_metadata(conn, r[0])) for r in rows]} + + +def event_metadata(conn, event_id): + row = conn.execute("SELECT received_at,source_timestamp,id_quality,truncated,content_missing FROM event_metadata WHERE event_id=?", (event_id,)).fetchone() + return dict(zip(('received_at', 'source_timestamp', 'id_quality', 'truncated', 'content_missing'), row)) if row else {'status': 'unknown_legacy'} def evidence_issues(record): @@ -589,8 +595,10 @@ def main(): cmd.add_argument("--project-id") cmd.add_argument("--name") if name == "configure": - cmd.add_argument("--repository", required=True) + cmd.add_argument("--repository") cmd.add_argument("--sync-on-finish", choices=("yes", "no")) + cmd.add_argument("--event-limit", type=int) + cmd.add_argument("--database-limit-mib", type=int) if name in {"context", "record", "export", "import", "snapshot"}: cmd.add_argument("--file", required=True) if name == "context": @@ -612,15 +620,24 @@ def main(): else: pid = binding(conn, args.project) if args.command == "configure": - remote = configure(conn, pid, args.repository) + if not args.repository and args.sync_on_finish != "no" and args.event_limit is None and args.database_limit_mib is None: + raise ValueError("Provide --repository or explicitly decline with --sync-on-finish no") + remote = configure(conn, pid, args.repository) if args.repository else None from learning_project import read_config, write_json - config_path = Path(args.project).resolve() / '.agents/learning/project.json' + config_path = Path(args.state).resolve() / 'project.json' if config_path.is_file() and Path(args.state).resolve() == config_path.parent: config = read_config(args.project) - config['remote'] = remote + if remote: + config['remote'] = remote if args.sync_on_finish is not None: config['sync_on_finish'] = args.sync_on_finish == 'yes' config['auto_sync'] = config['sync_on_finish'] + config['snapshot_choice'] = 'enabled' if config['sync_on_finish'] else 'declined' + for name, value in [('events', args.event_limit), ('database_bytes', args.database_limit_mib * 1024 * 1024 if args.database_limit_mib is not None else None)]: + if value is not None: + if value <= 0: + raise ValueError("Storage limits must be positive") + config.setdefault('storage_limits', {})[name] = value write_json(config_path, config) elif args.sync_on_finish == 'yes': raise ValueError("Completion-stage sync requires initialized project-local state") diff --git a/skills/agent-self-improvement/scripts/learning_project.py b/skills/agent-self-improvement/scripts/learning_project.py index 852e486..1e835d1 100644 --- a/skills/agent-self-improvement/scripts/learning_project.py +++ b/skills/agent-self-improvement/scripts/learning_project.py @@ -3,6 +3,7 @@ import hashlib import json import os +import re import shutil import sqlite3 import uuid @@ -13,10 +14,14 @@ from learning import legacy_has_project, project_id from learning_memory import bind, open_store from learning_processing import connect, lock +from learning_state import check_owner, state_root def read_config(project): - return json.loads((Path(project).resolve() / ".agents/learning/project.json").read_text(encoding="utf-8")) + config = check_owner(project, state_root(project)) + if config is None: + raise ValueError("Project state is not initialized") + return config def write_json(path, value): @@ -27,20 +32,21 @@ def write_json(path, value): os.replace(temporary, path) -def initialize(project, legacy=None, remote=None, *, local_only=False, memory_id=None): +def initialize(project, legacy=None, remote=None, *, local_only=False, memory_id=None, state=None): project = Path(project).resolve(strict=True) - target = project / ".agents/learning" + target = state_root(project, state) if (target / "project.json").exists(): - return {"result": "already_initialized", "state": str(target), "config": read_config(project)} + return {"result": "already_initialized", "state": str(target), "config": check_owner(project, target)} if not legacy and legacy_has_project(project): raise ValueError("Legacy project memory exists; provide --legacy explicitly before initialization") if target.exists(): raise ValueError("Partial state exists; inspect before migration") local_id = project_id(project) - stage = project / ".agents" / (".learning-migrate-" + uuid.uuid4().hex) + target.parent.mkdir(parents=True, exist_ok=True) + stage = target.parent / (".learning-migrate-" + uuid.uuid4().hex) copied = {} source = Path(legacy).resolve() if legacy else None - with lock(project / ".agents/.learning-locks", project): + with lock(target.parent / ".learning-locks", project): if target.exists(): raise ValueError("Project state was initialized concurrently") with closing(open_store(stage)) as out: @@ -66,6 +72,9 @@ def initialize(project, legacy=None, remote=None, *, local_only=False, memory_id for row in rows: out.execute(f"INSERT INTO {table} VALUES ({','.join('?' for _ in row)})", row) copied[table] = len(rows) + if "event_metadata" in tables: + for row in old.execute("SELECT * FROM event_metadata WHERE event_id IN (SELECT id FROM events WHERE project_id=?)", (local_id,)): + out.execute("INSERT INTO event_metadata VALUES (?,?,?,?,?,?)", row) digests = [r[0] for r in out.execute("SELECT DISTINCT digest FROM attachments")] for digest in digests: row = old.execute("SELECT * FROM blobs WHERE digest=?", (digest,)).fetchone() @@ -114,7 +123,8 @@ def initialize(project, legacy=None, remote=None, *, local_only=False, memory_id if out.execute("PRAGMA integrity_check").fetchone()[0] != "ok" or out.execute("PRAGMA foreign_key_check").fetchall(): raise ValueError("Migrated database integrity failed") config = {"schema_version": 1, "project_id": pid, "capture_project_id": local_id, - "name": project.name, "remote": remote, "remote_folder": project.name + "--" + pid, + "project_root": str(project), "snapshot_choice": "unresolved", + "name": project.name, "remote": remote, "remote_folder": (re.sub(r"[^A-Za-z0-9_.-]+", "-", project.name).strip(".-") or "project") + "--" + pid, "auto_sync": False, "telemetry": {"enabled": False, "base_url": "https://cloud.langfuse.com", "authorization_env": "LANGFUSE_MCP_AUTHORIZATION", "project_metadata_key": None, "project_metadata_value": None, "access_days": 30, "overlap_hours": 48, @@ -137,12 +147,13 @@ def initialize(project, legacy=None, remote=None, *, local_only=False, memory_id def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--project", required=True) + parser.add_argument("--state", type=Path) parser.add_argument("--legacy") parser.add_argument("--remote", help="Legacy address hint only; verify with memory configure before exchange") parser.add_argument("--local-only", action="store_true", help="Create local memory without telemetry, remote sync or tracing configuration") args = parser.parse_args() - result = initialize(args.project, args.legacy, args.remote, local_only=args.local_only) + result = initialize(args.project, args.legacy, args.remote, local_only=args.local_only, state=args.state) # Existing configurations may contain private connection details. result.pop('config', None) print(json.dumps(result)) diff --git a/skills/agent-self-improvement/scripts/learning_run.py b/skills/agent-self-improvement/scripts/learning_run.py index 3a374cc..6fec871 100644 --- a/skills/agent-self-improvement/scripts/learning_run.py +++ b/skills/agent-self-improvement/scripts/learning_run.py @@ -8,13 +8,14 @@ from learning_memory import records from learning_processing import digest from learning_project import read_config +from learning_state import state_root def load(project, run): if not isinstance(run, str) or not re.fullmatch(r'[a-zA-Z0-9_-]{1,80}', run): raise ValueError('A valid --run from begin is required') project = Path(project).resolve() - path = project / '.agents/learning/runs' / run / 'analysis.json' + path = state_root(project) / 'runs' / run / 'analysis.json' analysis = json.loads(path.read_text(encoding='utf-8')) if analysis['run_id'] != run or analysis['project_id'] != read_config(project)['project_id']: raise ValueError('Run belongs to a different project') @@ -23,7 +24,7 @@ def load(project, run): def sources(project, ids): config = read_config(project) - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) with closing(sqlite3.connect((state / 'experience.sqlite3').as_uri() + '?mode=ro', uri=True)) as conn: latest = {r['id']: r for r in records(conn, config['project_id'])} found, events = {}, {} @@ -85,7 +86,7 @@ def validate_analysis(project, analysis): raise ValueError('Cite archived context or explain its absence') for ref in refs: relative = Path(ref['path']) - archive = Path(project).resolve() / '.agents/learning/archive' + archive = state_root(project) / 'archive' path = (archive / relative).resolve(strict=True) if not path.is_relative_to(archive) or path.suffix != '.json': raise ValueError('Invalid archive reference') @@ -128,7 +129,7 @@ def verify_binding(proposal, state, require_link=True): raise ValueError('Legacy proposal lacks a run binding; explicit reassessment required') return project = Path(binding['project']).resolve() - if Path(state).resolve() != project / '.agents/learning': + if Path(state).resolve() != state_root(project): raise ValueError('Run and state do not belong to the same project') _, analysis = load(project, binding['run_id']) if require_link and analysis.get('proposal') != str(Path(state).resolve() / 'proposals' / (proposal['id'] + '.json')): diff --git a/skills/agent-self-improvement/scripts/learning_schedule.py b/skills/agent-self-improvement/scripts/learning_schedule.py index fdb3ef0..5d44085 100644 --- a/skills/agent-self-improvement/scripts/learning_schedule.py +++ b/skills/agent-self-improvement/scripts/learning_schedule.py @@ -1,4 +1,4 @@ -"""Inspect or remove a legacy project task. Scheduling is no longer supported.""" +"""Inspect or remove a legacy project task. Scheduling is no longer supported.""" import argparse import json import os @@ -7,6 +7,7 @@ from learning_memory import identifier from learning_project import read_config, write_json +from learning_state import state_root def install(project): @@ -49,7 +50,7 @@ def remove(project): if result['status'] != 'absent': raise ValueError('Task removal not verified') outcome = dict(result, status='disabled', scheduling_allowed=False) - write_json(Path(project).resolve() / '.agents/learning/scheduler.json', outcome) + write_json(state_root(project) / 'scheduler.json', outcome) return outcome @@ -57,6 +58,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('command', nargs='?', default='install', choices=('status', 'remove', 'install')) parser.add_argument('--project', required=True) + parser.add_argument("--state", type=Path) args = parser.parse_args() try: print(json.dumps({'status': inspect, 'remove': remove, 'install': install}[args.command](args.project))) diff --git a/skills/agent-self-improvement/scripts/learning_state.py b/skills/agent-self-improvement/scripts/learning_state.py new file mode 100644 index 0000000..2bb0881 --- /dev/null +++ b/skills/agent-self-improvement/scripts/learning_state.py @@ -0,0 +1,24 @@ +"""One state resolver for project capture, learning and exchange.""" +import argparse +import json +import os +from pathlib import Path + + +def state_root(project, state=None): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--state") + args, _ = parser.parse_known_args() + override = state or args.state or os.environ.get("AGENT_LEARNING_STATE") + return (Path(override).expanduser() if override else Path(project).expanduser().resolve() / ".agents/learning").resolve() + + +def check_owner(project, state): + descriptor = Path(state) / "project.json" + if descriptor.is_file(): + config = json.loads(descriptor.read_text(encoding="utf-8")) + owner = config.get("project_root") + if owner and Path(owner).resolve() != Path(project).resolve() and (Path(owner).exists() or Path(state).resolve() != Path(project).resolve() / ".agents/learning"): + raise ValueError("Selected state belongs to another project; explicitly migrate it") + return config + return None diff --git a/skills/agent-self-improvement/scripts/learning_tagging.py b/skills/agent-self-improvement/scripts/learning_tagging.py index 4fc8ca4..f10ca3e 100644 --- a/skills/agent-self-improvement/scripts/learning_tagging.py +++ b/skills/agent-self-improvement/scripts/learning_tagging.py @@ -10,6 +10,7 @@ from pathlib import Path from learning_project import read_config, write_json +from learning_state import state_root from learning_telemetry import assess, request_page @@ -31,7 +32,7 @@ def effective_connection(project, global_config=None): def connection_status(project, global_config=None): effective, conflicts = effective_connection(project, global_config) public, secret = effective.get('public_key', ''), effective.get('secret_key', '') - placeholders = ('...', 'PLACEHOLDER', 'YOUR_', 'ВАШ_', '<', '>') + placeholders = ('...', 'PLACEHOLDER', 'YOUR_', 'Р’РђРЁ_', '<', '>') ready = (isinstance(public, str) and isinstance(secret, str) and public.startswith('pk-lf-') and secret.startswith('sk-lf-') and len(public) > 12 and len(secret) > 12 @@ -68,7 +69,7 @@ def set_connection(project, enabled, approve=False, global_config=None, authenti set_connection(project, False, approve=True, global_config=global_config) config = read_config(project) config['telemetry']['connection_choice'] = 'pending_credentials' - write_json(project / '.agents/learning/project.json', config) + write_json(state_root(project) / 'project.json', config) raise ValueError('Authentication unavailable or failed; project tracing disabled') from None configure(project) path = project / '.codex/langfuse.json' @@ -81,7 +82,7 @@ def set_connection(project, enabled, approve=False, global_config=None, authenti config['telemetry']['base_url'] = effective['base_url'] config['telemetry']['connection_choice'] = 'connected' if enabled else 'declined' config['telemetry']['tag_validation'] = 'pending_readback' if enabled else 'disabled' - write_json(project / '.agents/learning/project.json', config) + write_json(state_root(project) / 'project.json', config) return {'status': 'configured_pending_readback' if enabled else 'disabled', 'project_id': config['project_id']} @@ -106,14 +107,14 @@ def configure(project): override = os.environ.get('LANGFUSE_CODEX_TAGS') if override: raise ValueError('LANGFUSE_CODEX_TAGS overrides project-local tags; resolve before setup') - backup = project / '.agents/learning/tracing-config.before.json' + backup = state_root(project) / 'tracing-config.before.json' if not backup.exists(): write_json(backup, {'existed': path.exists(), 'config': old}) write_json(path, dict(old, tags=expected)) config['telemetry'].update(required_tags=['webdev-agent-kit', friendly], stable_tag=identity, historical_tag=friendly, tag_validation='configured_pending_readback') - write_json(project / '.agents/learning/project.json', config) + write_json(state_root(project) / 'project.json', config) if json.loads(path.read_text(encoding='utf-8'))['tags'] != expected: raise ValueError('Tag configuration readback failed') return {'status': 'configured_pending_readback', 'tags': expected, @@ -128,7 +129,7 @@ def validate(project, fetch=request_page): # Strict stable-ID lookup. Friendly tags alone never certify the new installation. strict = dict(spec, required_tags=['webdev-agent-kit', 'project-id:' + config['project_id']], fields='core,basic,trace_context') - probe_file = project / '.agents/learning/tag-probe.json' + probe_file = state_root(project) / 'tag-probe.json' probe = json.loads(probe_file.read_text(encoding='utf-8')) if probe_file.exists() else None cursor, seen, good, rejected = None, set(), [], 0 exhausted = False @@ -162,7 +163,7 @@ def validate(project, fetch=request_page): except (RuntimeError, OSError) as exc: result = {'status': 'unavailable', 'database_only': True, 'reason': str(exc) if isinstance(exc, RuntimeError) else type(exc).__name__} - write_json(project / '.agents/learning/tag-validation.json', result) + write_json(state_root(project) / 'tag-validation.json', result) return result @@ -175,7 +176,7 @@ def probe(project, hook): raise ValueError('Tracing is explicitly disabled') session, turn = str(uuid.uuid4()), str(uuid.uuid4()) now = datetime.now(UTC) - folder = project / '.agents/learning/runs/tag-validation' + folder = state_root(project) / 'runs/tag-validation' folder.mkdir(parents=True, exist_ok=True) path = folder / (session + '.jsonl') events = [('session_meta', {'id': session, 'cwd': str(project), 'source': 'tag-validation'}), @@ -192,7 +193,7 @@ def probe(project, hook): text=True, capture_output=True, timeout=45, env=env) report = {'session_id': session, 'turn_id': turn, 'status': 'sent_pending_readback' if result.returncode == 0 else 'hook_failed', 'synthetic': True, 'model_called': False, 'hook': str(hook)} - write_json(project / '.agents/learning/tag-probe.json', report) + write_json(state_root(project) / 'tag-probe.json', report) return report @@ -202,6 +203,7 @@ def probe(project, hook): parser.add_argument('--project', required=True) parser.add_argument('--hook') parser.add_argument('--approve', action='store_true') + parser.add_argument("--state", type=Path) args = parser.parse_args() if args.command in {'enable', 'disable'}: result = set_connection(args.project, args.command == 'enable', args.approve) diff --git a/skills/agent-self-improvement/scripts/learning_telemetry.py b/skills/agent-self-improvement/scripts/learning_telemetry.py index 2200dcf..d1e6311 100644 --- a/skills/agent-self-improvement/scripts/learning_telemetry.py +++ b/skills/agent-self-improvement/scripts/learning_telemetry.py @@ -8,11 +8,11 @@ import urllib.parse import urllib.request from datetime import UTC, datetime, timedelta -from pathlib import Path from learning import redact from learning_processing import lock from learning_project import read_config, write_json +from learning_state import state_root def timestamp(value): @@ -119,7 +119,7 @@ def export_selected(project, windows, fetch=request_page, now=None): """Export only selected incident windows, with bounded resumable readback.""" config = read_config(project) spec = config['telemetry'] - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) if not spec.get('enabled'): return {'status': 'disabled', 'database_only': True} if not (spec.get('required_tags') or (spec.get('project_metadata_key') and spec.get('project_metadata_value'))): @@ -189,7 +189,7 @@ def export_selected(project, windows, fetch=request_page, now=None): def export(project, fetch=request_page, now=None): config = read_config(project) - state = Path(project).resolve() / ".agents/learning" + state = state_root(project) spec = config["telemetry"] if not spec.get("enabled") or not (spec.get("required_tags") or (spec.get("project_metadata_key") and spec.get("project_metadata_value"))): result = {"status": "project_mapping_unavailable", "database_only": True} @@ -276,7 +276,7 @@ def export(project, fetch=request_page, now=None): def prepare(project, run_id, months, windows=None): if not re.fullmatch(r"[a-zA-Z0-9_-]{1,80}", run_id) or any(not re.fullmatch(r"\d{4}-\d{2}", m) for m in months): raise ValueError("Invalid run ID or month") - state = Path(project).resolve() / ".agents/learning" + state = state_root(project) config = read_config(project) destination = state / "runs" / run_id if destination.resolve().parent != (state / "runs").resolve(): @@ -316,7 +316,7 @@ def prepare(project, run_id, months, windows=None): def cleanup(project, run_id): if not re.fullmatch(r"[a-zA-Z0-9_-]{1,80}", run_id): raise ValueError("Invalid run ID") - state = Path(project).resolve() / ".agents/learning" + state = state_root(project) folder = state / "runs" / run_id if folder.resolve().parent != (state / "runs").resolve(): raise ValueError("Unsafe run directory") diff --git a/skills/agent-self-improvement/scripts/learning_workflow.py b/skills/agent-self-improvement/scripts/learning_workflow.py index 9e3b572..96cf6cf 100644 --- a/skills/agent-self-improvement/scripts/learning_workflow.py +++ b/skills/agent-self-improvement/scripts/learning_workflow.py @@ -8,9 +8,10 @@ from datetime import UTC, datetime, timedelta from pathlib import Path -from learning_exchange import drain, enqueue +from learning_exchange import drain, enqueue, snapshot_enabled from learning_memory import records from learning_project import read_config, write_json +from learning_state import state_root from learning_telemetry import cleanup, export, export_selected, prepare, timestamp @@ -33,7 +34,7 @@ def verification_plan(paths, changes=()): def curate_queue(project): """Index raw sessions for separate semantic curation; never invent a lesson.""" - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) config = read_config(project) with closing(sqlite3.connect(state / 'experience.sqlite3')) as conn, conn: conn.execute('CREATE TABLE IF NOT EXISTS curation_queue (client TEXT, session_id TEXT, event_count INTEGER, last_event TEXT, state TEXT, PRIMARY KEY(client, session_id))') @@ -56,7 +57,7 @@ def maintenance(project): def begin(project, ids): - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) config = read_config(project) with closing(sqlite3.connect(state / 'experience.sqlite3')) as conn: latest = {r['id']: r for r in records(conn, config['project_id'])} @@ -115,7 +116,7 @@ def finish(project, run): proposal_path = analysis.get('proposal') if not proposal_path: raise ValueError('Run lacks its exact linked proposal') - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) path = Path(proposal_path).resolve(strict=True) if not path.is_relative_to(state / 'proposals'): raise ValueError('Proposal is outside this project store') @@ -125,7 +126,7 @@ def finish(project, run): def finish_locked(project, run): - state = Path(project).resolve() / '.agents/learning' + state = state_root(project) # Validate path before reading any caller-controlled location. import re if not re.fullmatch(r'[a-zA-Z0-9_-]{1,80}', run): @@ -204,9 +205,29 @@ def finish_locked(project, run): if not (folder / 'completion.json').exists(): write_json(folder / 'completion.json', {'decisions': decisions, 'finished_at': datetime.now(UTC).isoformat(), 'reflection': reflection}) - snapshot = enqueue(project) - sync = drain(project) - return {'snapshot': snapshot, 'sync': sync, 'cleanup': cleanup(project, run)} + from learning_health import usage + # Completion is already durable; exchange failures cannot invalidate learning. + try: + link = folder / 'snapshot.json' + config = read_config(project) + if not snapshot_enabled(config): + snapshot = {'status': 'disabled'} + elif link.exists(): + snapshot = dict(json.loads(link.read_text(encoding='utf-8')), status='linked') + else: + snapshot = enqueue(project) + if snapshot.get('snapshot'): + write_json(link, {'snapshot': snapshot['snapshot'], 'repository': config.get('remote')}) + sync = drain(project) if snapshot.get('status') != 'disabled' else {'status': 'disabled'} + except (ValueError, OSError, sqlite3.Error) as exc: + snapshot = {'status': 'pending', 'reason': type(exc).__name__} + sync = {'status': 'pending_retry', 'retry': 'next_learning_or_explicit_request'} + try: + storage = usage(project) + except (OSError, ValueError, sqlite3.Error) as exc: + storage = {'status': 'unverified', 'reason': type(exc).__name__} + return {'learning': 'completed', 'snapshot': snapshot, 'sync': sync, + 'storage': storage, 'cleanup': cleanup(project, run)} def main(): @@ -216,6 +237,7 @@ def main(): parser.add_argument('--record', action='append', default=[]) parser.add_argument('--run') parser.add_argument('--file') + parser.add_argument("--state", type=Path) args = parser.parse_args() if args.command == 'maintenance': parser.error('Background maintenance is disabled; explicit workflow stages only.') @@ -223,7 +245,7 @@ def main(): from learning_exchange import restore print(json.dumps(restore(args.project, args.file))) return - functions = {'maintenance': maintenance, 'snapshot': enqueue, 'sync': lambda project: drain(project, explicit=True), 'archive': export, 'curation-queue': curate_queue} + functions = {'maintenance': maintenance, 'snapshot': lambda project: enqueue(project, explicit=True), 'sync': lambda project: drain(project, explicit=True), 'archive': export, 'curation-queue': curate_queue} result = begin(args.project, args.record) if args.command == 'begin' else finish(args.project, args.run) if args.command == 'finish' else functions[args.command](args.project) print(json.dumps(result, ensure_ascii=False)) diff --git a/skills/agent-self-improvement/scripts/test_hook_bridge.py b/skills/agent-self-improvement/scripts/test_hook_bridge.py index b80a113..6c5f851 100644 --- a/skills/agent-self-improvement/scripts/test_hook_bridge.py +++ b/skills/agent-self-improvement/scripts/test_hook_bridge.py @@ -37,7 +37,7 @@ def capture_only(args, **kwargs): self.assertEqual(args[2:], ['capture', '--project', directory]) self.assertEqual(Path(args[1]).name, 'learning.py') return original(args, **kwargs) - with patch.dict(os.environ, {'AGENT_LEARNING_STATE': str(state)}), patch.object(sys, 'argv', [str(BRIDGE), '--client', 'codex']), patch.object(sys, 'stdin', io.StringIO(json.dumps(event))), patch.object(sys, 'stdout', io.StringIO()), patch('subprocess.run', side_effect=capture_only): + with patch.dict(os.environ, {'AGENT_LEARNING_STATE': str(state)}), patch.object(sys, 'argv', [str(BRIDGE), '--client', 'codex']), patch.object(sys, 'stdin', io.TextIOWrapper(io.BytesIO(json.dumps(event).encode('utf-8')), encoding='utf-8')), patch.object(sys, 'stdout', io.StringIO()), patch('subprocess.run', side_effect=capture_only): runpy.run_path(str(BRIDGE), run_name='__main__') self.assertEqual(len(calls), 1) with closing(sqlite3.connect(state / 'experience.sqlite3')) as conn: diff --git a/skills/agent-self-improvement/scripts/test_learning_benchmark.py b/skills/agent-self-improvement/scripts/test_learning_benchmark.py index 4c7a0a3..5ca6ffa 100644 --- a/skills/agent-self-improvement/scripts/test_learning_benchmark.py +++ b/skills/agent-self-improvement/scripts/test_learning_benchmark.py @@ -33,6 +33,8 @@ def test_offline_replay_never_unlocks_automatic_apply(self): project = root / "project" project.mkdir() state = root / "state" + from learning_memory import open_store + open_store(state).close() # Existing legacy standalone store. def run(script, *arguments, data=None, expect=0): proc = subprocess.run( diff --git a/skills/agent-self-improvement/scripts/test_learning_capture_snapshots.py b/skills/agent-self-improvement/scripts/test_learning_capture_snapshots.py new file mode 100644 index 0000000..a7e3686 --- /dev/null +++ b/skills/agent-self-improvement/scripts/test_learning_capture_snapshots.py @@ -0,0 +1,228 @@ +"""Project isolation, consent, capture integrity and pressure regressions.""" +import base64 +import json +import os +import subprocess +import sys +import tempfile +import unittest +from contextlib import closing +from pathlib import Path +from unittest.mock import patch + +from learning import capture, retain_raw, set_mode +from learning_exchange import drain, enqueue, restore +from learning_health import health_status, usage +from learning_memory import open_store, save_records, session_context +from learning_project import initialize, read_config, write_json +from learning_state import state_root +from learning_workflow import begin + + +class CaptureSnapshotTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.project = self.root / 'project with spaces' + self.project.mkdir() + self.state = self.root / 'external memory' + self.env = patch.dict(os.environ, {'AGENT_LEARNING_STATE': str(self.state)}) + self.env.start() + self.addCleanup(self.env.stop) + initialize(self.project) + + def consent(self, enabled=True, remote='example/private-memory'): + config = read_config(self.project) + config.update(remote=remote, auto_sync=enabled, sync_on_finish=enabled, + snapshot_choice='enabled' if enabled else 'declined') + write_json(self.state / 'project.json', config) + with closing(open_store(self.state)) as conn, conn: + conn.execute('UPDATE memory_projects SET sync_repository=?', (remote,)) + return config + + def test_cli_resolver_all_spellings_and_other_working_directory(self): + script = Path(__file__).with_name('learning.py') + for project_args in (['--project', str(self.project)], ['--project=' + str(self.project)]): + result = subprocess.run([sys.executable, str(script), '--state', str(self.state), 'status', *project_args], + cwd=self.root, capture_output=True, text=True, check=True) + self.assertEqual(json.loads(result.stdout)['state'], str(self.state)) + self.assertFalse((self.project / '.agents/learning').exists()) + other = self.root / 'second project' + other.mkdir() + with self.assertRaisesRegex(ValueError, 'another project'): + initialize(other) + with patch.dict(os.environ, {'AGENT_LEARNING_STATE': str(self.root / 'second memory')}): + initialize(other) + self.assertNotEqual(read_config(other)['project_id'], read_config_at(self.state)['project_id']) + + def test_unconfigured_status_and_capture_do_not_create_store(self): + script = Path(__file__).with_name('learning.py') + missing = self.root / 'never initialized' + for command in ('status', 'capture'): + result = subprocess.run([sys.executable, str(script), '--state', str(missing), command, + '--project', str(self.project)], input='{}', text=True, + capture_output=True, check=True) + self.assertFalse(json.loads(result.stdout)['recording']) + self.assertFalse(missing.exists()) + + def test_no_snapshot_without_consent_and_decline_preserves_old_queue(self): + self.assertEqual(enqueue(self.project)['status'], 'disabled') + self.assertFalse(list((self.state / 'snapshots').glob('*'))) + self.consent() + enqueue(self.project) + before = {p.name: p.read_bytes() for p in (self.state / 'outbox').glob('*')} + self.consent(False) + with patch('learning_exchange.gh') as api: + self.assertEqual(enqueue(self.project)['status'], 'disabled') + self.assertEqual(drain(self.project)['status'], 'disabled') + api.assert_not_called() + self.assertEqual(before, {p.name: p.read_bytes() for p in (self.state / 'outbox').glob('*')}) + + def test_snapshot_excludes_archives_and_queue_is_destination_bound(self): + self.consent() + write_json(self.state / 'archive/2026-01/trace.json', {'secret': 'not for export'}) + result = enqueue(self.project) + payload = json.loads((self.state / 'snapshots' / (result['snapshot'] + '.json')).read_text(encoding='utf-8')) + self.assertFalse(payload['raw_events_included']) + jobs = [json.loads(p.read_text(encoding='utf-8')) for p in (self.state / 'outbox').glob('*.json')] + self.assertEqual(len(jobs), 1) + self.assertTrue(jobs[0]['path'].startswith('snapshots/')) + self.consent(remote='example/new-destination') + calls = [] + def api(route, *args, **kwargs): + calls.append(route) + return {'private': True} + result = drain(self.project, api) + self.assertEqual(result['uploaded'], 0) + self.assertEqual(calls, ['repos/example/new-destination']) + enqueue(self.project) + self.assertEqual(len(list((self.state / 'outbox').glob('*.json'))), 2) + + def test_capture_ids_timestamps_and_health_without_contents(self): + event = dict(client='test', session_id='session', message_id='one', kind='user', + content='token=secret-value-value', timestamp='2020-01-01T00:00:00+00:00') + with closing(open_store(self.state)) as conn: + set_mode(conn, self.project, True) + first = capture(conn, self.project, event) + self.assertTrue(capture(conn, self.project, event)['duplicate']) + del event['message_id'] + second = capture(conn, self.project, event) + third = capture(conn, self.project, event) + self.assertNotEqual(second['id'], third['id']) + page = session_context(conn, self.project, 'test', 'session') + metadata = page['events'][0]['capture_metadata'] + self.assertNotEqual(metadata['source_timestamp'], metadata['received_at']) + self.assertNotIn('secret-value-value', str(page)) + self.assertTrue(first['recorded']) + self.assertEqual(health_status(self.project)['status'], 'observed') + self.assertNotIn(b'secret-value-value', (self.state / 'capture-health.sqlite3').read_bytes()) + + def test_empty_private_repository_bootstraps_with_readback(self): + from learning_exchange import drain_batch + self.consent() + enqueue(self.project) + stored = {} + def api(route, *args, **kwargs): + if route.endswith('/branches?per_page=1'): + return [] + if '/contents/' not in route: + return {'private': True, 'size': 0, 'default_branch': 'main'} + if kwargs.get('input_data'): + stored[route] = json.loads(kwargs['input_data'])['content'] + return {} + return {'content': stored[route]} + self.assertEqual(drain_batch(self.project, api)['status'], 'synced') + self.assertEqual(len(stored), 1) + self.assertFalse(json.loads(base64.b64decode(next(iter(stored.values()))))['raw_events_included']) + + def test_hook_bounds_input_and_storage_lock_without_blocking_host(self): + bridge = Path(__file__).resolve().parents[1] / 'assets/integrations/hook_bridge.py' + oversized = subprocess.run([sys.executable, str(bridge), '--client', 'codex'], + input='x' * 262145, text=True, capture_output=True, timeout=8) + self.assertEqual(oversized.returncode, 0) + with closing(open_store(self.state)) as conn: + set_mode(conn, self.project, True) + conn.execute('BEGIN IMMEDIATE') + locked = subprocess.run([sys.executable, str(bridge), '--client', 'codex'], + input=json.dumps(dict(hook_event_name='Stop', cwd=str(self.project), + session_id='s', turn_id='1', last_assistant_message='test')), + text=True, capture_output=True, timeout=8) + conn.rollback() + self.assertEqual(locked.returncode, 0) + self.assertEqual(json.loads(locked.stdout), {}) + counters = health_status(self.project)['counters'] + self.assertTrue(any(r['status'] == 'error' for r in counters)) + + def test_installed_hook_retains_external_state_without_environment_override(self): + import shlex + installer = Path(__file__).with_name('install_hooks.py') + subprocess.run([sys.executable, str(installer), '--project', str(self.project), + '--state', str(self.state), '--approve'], capture_output=True, check=True) + hooks = json.loads((self.project / '.codex/hooks.json').read_text(encoding='utf-8')) + command = hooks['hooks']['Stop'][-1]['hooks'][0]['command'] + with closing(open_store(self.state)) as conn: + set_mode(conn, self.project, True) + env = dict(os.environ) + env.pop('AGENT_LEARNING_STATE', None) + result = subprocess.run(shlex.split(command), env=env, capture_output=True, text=True, check=True, + input=json.dumps(dict(hook_event_name='Stop', cwd=str(self.project), + session_id='s', turn_id='stable', last_assistant_message='captured'))) + self.assertEqual(json.loads(result.stdout), {}) + with closing(open_store(self.state)) as conn: + self.assertEqual(conn.execute('SELECT COUNT(*) FROM events').fetchone()[0], 1) + self.assertFalse((self.project / '.agents/learning').exists()) + + def test_pressure_asks_without_deleting_or_disabling(self): + config = read_config(self.project) + config['storage_limits'] = {'events': 1, 'database_bytes': 1} + write_json(self.state / 'project.json', config) + with closing(open_store(self.state)) as conn: + set_mode(conn, self.project, True) + capture(conn, self.project, dict(client='test', session_id='session', message_id='one', + kind='user', content='old unprocessed event', timestamp='2020-01-01T00:00:00+00:00')) + result = usage(self.project) + self.assertTrue(result['cleanup_permission_required']) + self.assertEqual(result['events'], 1) + self.assertEqual(result['preview']['eligible'], 0) + with closing(open_store(self.state)) as conn: + self.assertEqual(conn.execute('SELECT recording FROM projects').fetchone()[0], 1) + self.assertEqual(conn.execute('SELECT COUNT(*) FROM events').fetchone()[0], 1) + + def test_restore_structured_snapshot_and_external_analysis(self): + from test_learning_memory import ProjectMemoryTests + config = self.consent() + fixture = ProjectMemoryTests() + fixture.pid = config['project_id'] + with closing(open_store(self.state)) as conn: + save_records(conn, config['project_id'], fixture.sample()) + with patch('learning_workflow.export_selected', return_value={'database_only': True}): + analysis = begin(self.project, ['lesson']) + self.assertTrue((self.state / 'runs' / analysis['run_id'] / 'analysis.json').is_file()) + snapshot = enqueue(self.project)['snapshot'] + backup = self.state / 'snapshots' / (snapshot + '.json') + with patch.dict(os.environ, {'AGENT_LEARNING_STATE': str(self.root / 'restored')}): + initialize(self.project, memory_id=config['project_id']) + self.assertGreater(restore(self.project, backup)['inserted'], 0) + self.assertEqual(state_root(self.project), self.root / 'restored') + + def test_referenced_evidence_survives_explicit_cleanup(self): + from test_learning_memory import ProjectMemoryTests + config = read_config(self.project) + fixture = ProjectMemoryTests() + fixture.pid = config['project_id'] + with closing(open_store(self.state)) as conn: + set_mode(conn, self.project, True) + event = capture(conn, self.project, dict(client='test', session_id='session', message_id='one', + kind='user', content='evidence', timestamp='2020-01-01T00:00:00+00:00')) + items = fixture.sample() + episode = next(r for r in items if r['kind'] == 'episode') + episode['payload'].update(source_event_ids=[event['id']], curation_state='reviewed', curation_reason='Reviewed this source event') + save_records(conn, config['project_id'], items) + result = retain_raw(conn, self.project, 30, apply=True, processed_only=True) + self.assertEqual(result['deleted'], 0) + self.assertEqual(conn.execute('SELECT COUNT(*) FROM events').fetchone()[0], 1) + + +def read_config_at(state): + return json.loads((state / 'project.json').read_text(encoding='utf-8')) diff --git a/skills/agent-self-improvement/scripts/test_learning_closure.py b/skills/agent-self-improvement/scripts/test_learning_closure.py index 43ba8c8..24a8f04 100644 --- a/skills/agent-self-improvement/scripts/test_learning_closure.py +++ b/skills/agent-self-improvement/scripts/test_learning_closure.py @@ -67,6 +67,10 @@ def prepared(self, change=False): def test_linked_completion_is_idempotent_and_offline_queue_survives(self): analysis, folder, args = self.prepared(change=True) + self.config.update(auto_sync=True, sync_on_finish=True, remote='example/private-memory') + write_json(self.state / 'project.json', self.config) + with closing(open_store(self.state)) as conn, conn: + conn.execute('UPDATE memory_projects SET sync_repository=?', ('example/private-memory',)) with patch('learning_workflow.drain', return_value={'status': 'pending_retry'}): first = finish(self.project, analysis['run_id']) receipt = (folder / 'completion.json').read_bytes() @@ -79,6 +83,33 @@ def test_linked_completion_is_idempotent_and_offline_queue_survives(self): self.assertEqual(first['sync']['status'], 'pending_retry') self.assertTrue(list((self.state / 'outbox').glob('*.json'))) + def test_no_change_completion_respects_refusal_and_checks_pressure(self): + analysis, folder, args = self.prepared() + self.config.update(snapshot_choice='declined', storage_limits={'events': 50000, 'database_bytes': 1}) + write_json(self.state / 'project.json', self.config) + with patch('learning_workflow.drain') as upload: + result = finish(self.project, analysis['run_id']) + upload.assert_not_called() + self.assertEqual(result['learning'], 'completed') + self.assertEqual(result['snapshot']['status'], 'disabled') + self.assertTrue(result['storage']['cleanup_permission_required']) + self.assertFalse(list((self.state / 'snapshots').glob('*'))) + self.assertFalse(list((self.state / 'outbox').glob('*'))) + + def test_snapshot_link_survives_repeated_completion(self): + analysis, folder, args = self.prepared() + self.config.update(auto_sync=True, sync_on_finish=True, remote='example/private-memory') + write_json(self.state / 'project.json', self.config) + with closing(open_store(self.state)) as conn, conn: + conn.execute('UPDATE memory_projects SET sync_repository=?', ('example/private-memory',)) + with patch('learning_workflow.drain', return_value={'status': 'pending_retry'}): + result = finish(self.project, analysis['run_id']) + linked = (folder / 'snapshot.json').read_bytes() + with patch('learning_workflow.enqueue', side_effect=AssertionError('must reuse snapshot')): + repeated = finish(self.project, analysis['run_id']) + self.assertEqual((folder / 'snapshot.json').read_bytes(), linked) + self.assertEqual(result['snapshot']['snapshot'], repeated['snapshot']['snapshot']) + def test_changed_rule_or_missing_reflection_blocks_finish(self): analysis, folder, args = self.prepared() current = json.loads((folder / 'analysis.json').read_text()) diff --git a/skills/agent-self-improvement/scripts/test_learning_cycle.py b/skills/agent-self-improvement/scripts/test_learning_cycle.py index 14ff533..e0f1d2e 100644 --- a/skills/agent-self-improvement/scripts/test_learning_cycle.py +++ b/skills/agent-self-improvement/scripts/test_learning_cycle.py @@ -19,6 +19,9 @@ def setUp(self): self.addCleanup(self.temp.cleanup) self.base = Path(self.temp.name) self.state = self.base / "state" + # Exercise the retained legacy standalone proposal path. + from learning_memory import open_store + open_store(self.state).close() self.project = self.base / "project" self.repo = self.base / "skill-pack" self.project.mkdir() diff --git a/skills/agent-self-improvement/scripts/test_learning_workflow.py b/skills/agent-self-improvement/scripts/test_learning_workflow.py index 9dfc3ed..e49524d 100644 --- a/skills/agent-self-improvement/scripts/test_learning_workflow.py +++ b/skills/agent-self-improvement/scripts/test_learning_workflow.py @@ -121,7 +121,7 @@ def test_public_remote_and_outbox_escape(self): self.assertEqual(drain(self.project, lambda *args: {'private': False})['status'], 'pending_retry') job = next((self.state / 'outbox').glob('*.json')) write_json(job, {'path': '../../outside', 'sha256': 'x'}) - self.assertEqual(drain(self.project, lambda *args: {'private': True})['status'], 'pending_retry') + self.assertEqual(drain(self.project, lambda *args: {'private': True})['status'], 'blocked_review') self.assertTrue(job.exists()) def test_risk_and_empty_queue_do_not_train(self): diff --git a/skills/project-onboarding-adapter/SKILL.md b/skills/project-onboarding-adapter/SKILL.md index 6a55174..b901e20 100644 --- a/skills/project-onboarding-adapter/SKILL.md +++ b/skills/project-onboarding-adapter/SKILL.md @@ -142,9 +142,11 @@ If capture intent is unresolved, explain the local collection scope and ask once otherwise honor the existing decision without repeated confirmation. Explicit capture setup already authorizes its documented local hooks; do not ask again. -Offer optional private-repository experience storage only during adaptation. -Only if the user supplies a repository, follow the memory reference to verify it -and configure its local address. No repository means local-only operation. +Offer optional structured snapshots to a user-selected private repository after +each completed learning cycle. Explain that this choice enables bounded uploads, +verify privacy/write access, then configure `--sync-on-finish yes`. Save refusal +with `--sync-on-finish no`: no automatic snapshots or outbox entries. Keep local +memory available. Existing address-only configuration requires a consent choice. Store decisions and separate setup/verification states in host-local `.agents/project/learning-profile.md`; link it from the approved host pointer for project-scoped recall. Recall never enables capture or sync. Report capture @@ -153,7 +155,7 @@ but unobserved hooks as unverified. Never call the whole learning pipeline activ merely because recording is enabled or tests pass. An update alone preserves these decisions and does not reopen onboarding. -Plan Mode only describes changes. Never upload automatically, alter native trust, +Plan Mode only describes changes. Never upload during adaptation, alter native trust, install tooling without authority or embed a user's repository in the Kit. ## Codex Model Setup During Onboarding diff --git a/skills/webdev-kit-updater/references/upgrade-procedure.md b/skills/webdev-kit-updater/references/upgrade-procedure.md index d8551b7..0a44daa 100644 --- a/skills/webdev-kit-updater/references/upgrade-procedure.md +++ b/skills/webdev-kit-updater/references/upgrade-procedure.md @@ -36,7 +36,9 @@ stage and activate; record state; verify and recover. switch is a separate migration. A shared plugin may affect other projects: disclose that and obtain scope approval before activating it. - Treat host `.agents/project/**`, `.agents/learning/**`, migration stages - `.agents/.learning-migrate-*`, learning locks, custom skills, unrelated client rules, + `.agents/.learning-migrate-*`, learning locks, the explicitly resolved learning + store (including external paths or alternate paths inside the bundle), custom + skills, unrelated client rules, configs, credentials, history, caches, and app files as protected. Vendor ownership comes from the pristine old target inventory, not directory names. Unknown files inside `.agents/skills/` are not disposable vendor files. diff --git a/tool-capabilities-manifest.json b/tool-capabilities-manifest.json index fd108ab..c5f7bea 100644 --- a/tool-capabilities-manifest.json +++ b/tool-capabilities-manifest.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.9.1", "capabilities": { "client_platform_docs": { "purpose": "Fetch current host-client installation, plugin, instruction, tool, and configuration documentation.",