diff --git a/loopx/capabilities/content_ops/README.md b/loopx/capabilities/content_ops/README.md index eda3b8a459..a68a2c4af6 100644 --- a/loopx/capabilities/content_ops/README.md +++ b/loopx/capabilities/content_ops/README.md @@ -91,3 +91,88 @@ compatibility facade and delegates those packets without changing their output. The provider does not open a browser, read a timeline, or publish. A real browser session remains owner-controlled, and every external write still needs the exact account, body, media/link plan, source references, and stop condition. + +### Bundled experience before X preparation / 发 X 前的经验种子 + +Every new Agent can read the versioned, public-safe +[`x-composer-preflight-v1.json`](experiences/x-composer-preflight-v1.json) +from either existing provider entry point, without a memory account or network: + +```bash +loopx value-connectors source-map --connector social_browser_x --format json +loopx value-connectors install-check --connector social_browser_x --format json +``` + +The JSON `operating_experience` field contains the full seed, version, package +content digest, applicability, observations, procedure and memory initialization +recipe. Markdown output exposes the compact lesson and points to JSON. The +seed ships in the wheel, not only in the source tree. Read it before editing a +composer, and revalidate the final body/card after every subsequent edit. + +新的 Agent 不需要继承旧会话,也不需要先有 memory provider,就能从以上入口读到 +完整经验种子。它属于 `content-ops` 的内置 `social_browser_x` provider;不新增 +capability、扩展、内核规则或第二套记忆存储。 + +The seed includes a negative publication result: entering the main URL last +restored its composer preview, but a published long post bound `card.rest_id` +to the first supporting URL in its `note_tweet` URL entities instead. Input +order, body order and published-card selection are distinct. Retain every +required link and try the intended URL first in the actual body; this remains +a mitigation **without a verified successful published readback**, not an X +contract. Unknown target binding stays unknown. A rich-text `fill` can leave old paragraphs behind; +scoped native select-all/clear/insert plus exact readback is the recovery path. +Composer readiness and published-post verification are separate evidence. Do +not remove requested links or publish a test merely to obtain a card. + +这里保存的是包含失败反例的操作经验:最后输入主链接只能改变编辑器预览,曾经 +在发布后仍绑定到第一个合作者链接。保留多个链接,把目标链接放到正文第一个 +URL 位置,是待发布后验证的缓解方案,不是已证实的修复。输入先后、正文顺序和 +发布端选卡必须分开。不得为验证卡片擅自发帖,也不得用删链接代替解决问题。 + +### Initialize an Agent's own memory / 初始化自己的记忆 + +Bundled discovery is implemented; automatic seed import is intentionally **not** +performed. A host can initialize its own provider through the existing +Reward Memory `scoped_feedback` adapter, after corpus-owner authorization and +current-artifact review. Repository publication is not permission to write a +user's provider, and another Agent's private corpus is not a shared seed store. + +1. Read `operating_experience` and deduplicate by the configured Goal/Agent scope + plus seed id, version and `content_digest`. Review newer revisions rather + than overwriting local corrections or reviving retired memories. +2. Resolve the Agent's existing + [Reward Memory configuration](../reward_memory/README.md). Keep provider + credentials and scope references in its owner-local configuration. +3. Prepare one `scoped_feedback_reward_memory_event_v0` using the seed's + `content_summary`, `target_class=procedural_experience`, and source kind + `reviewed_learning_card`. Use the seed id/version/digest as a stable source + reference; fill workspace/project/user/peer and surface from the configured + corpus. Set `requested_action_scopes=[]` and `raw_content_captured=false`. + The configured standing policy still reviews the event. Set + `current_artifact_verified=true` only after actual verification; package + presence alone does not satisfy it. Never copy a ready-made verification flag. +4. Use the existing scoped import and require its exact provider readback: + + ```bash + # event.json contains adapter, event and observed_at; preview first. + loopx reward-memory ingest-event --goal-id --agent-id \ + --input event.json --format json + # Only under the corpus owner's existing write authorization: + loopx reward-memory ingest-event --goal-id --agent-id \ + --input event.json --execute --format json + ``` + +5. For subsequent automatic retrieval, use the existing + [`agent_workflow.turn_admission`](../agent_turn_recall/README.md) surface, + configured to the compatible corpus with automatic recall enabled. Name + actual X preparation in the selected Todo so its bounded query can retrieve + the relevant experience. Memory availability is not proof of recall, and + recall is not proof that the Agent applied or verified the lesson. + +经验种子读取、写入个人 memory、下一轮召回、当前操作验证,是四个不同结果。 +未配置或不可用时,直接参考仓库种子并如实报告,不冒称“已入库/已召回”。后续 +平台行为变化应由新的验证经验修订或淘汰旧记录;不要提升为全局强制偏好。 + +No frontend/Lark configuration changes are introduced: these commands only +read packaged public guidance and retain existing memory settings and gates. +There are no new background imports, browser operations or external writes. diff --git a/loopx/capabilities/content_ops/catalog_entry.py b/loopx/capabilities/content_ops/catalog_entry.py index de3abdf01c..13d7acb29b 100644 --- a/loopx/capabilities/content_ops/catalog_entry.py +++ b/loopx/capabilities/content_ops/catalog_entry.py @@ -22,6 +22,11 @@ ), "entry_command": "loopx content-ops aggregate-packets --format json", "commands": [ + { + "command": "loopx value-connectors source-map --connector social_browser_x --format json", + "purpose": "Before X preparation, read the provider's bundled operating experience and optional memory initialization recipe.", + "write_boundary": "packaged seed read only; no browser or memory-provider calls", + }, { "command": "loopx content-ops exploration-plan --format json", "purpose": "Plan source lanes before reading connector material.", diff --git a/loopx/capabilities/content_ops/experiences/x-composer-preflight-v1.json b/loopx/capabilities/content_ops/experiences/x-composer-preflight-v1.json new file mode 100644 index 0000000000..fb7614d7ab --- /dev/null +++ b/loopx/capabilities/content_ops/experiences/x-composer-preflight-v1.json @@ -0,0 +1,54 @@ +{ + "schema_version": "content_ops_provider_experience_seed_v0", + "seed_id": "social_browser_x.composer_preflight", + "version": 1, + "observed_on": "2026-09-13", + "provider_id": "social_browser_x", + "target_class": "procedural_experience", + "authority": "advisory", + "title": "Preserve multiple X links while verifying the intended preview card", + "applicability": [ + "Preparing or revising an X browser composer with multiple URLs", + "Handing an editable draft back for manual publication" + ], + "content_summary": "X composer previews can disagree with published cards. A long post bound its card to the first supporting URL despite a correct main-link preview. Preserve all links; try the intended URL first in the actual body, not merely last in input order. This mitigation is unproven without published readback. Native clear/insert and exact readback avoid rich-text duplication. Never publish to test without authorization.", + "observations": [ + "A supporting PR or repository URL can replace the intended main-link card.", + "Entering the intended URL last restored a composer preview but did not preserve that selection after publication. This is a failed publication mitigation, not a success recipe.", + "In a published long post observed on 2026-09-10, card.rest_id matched the first supporting URL in note_tweet entities rather than the intended fourth URL; the card lacked renderable content.", + "Moving the main URL first later retained a supporting preview in the editor; no corresponding successful published readback was recorded. Body order and input order are distinct, and neither is a universal guarantee.", + "A rich-text fill operation can replace only part of an existing editor, leaving duplicated paragraphs." + ], + "procedure": [ + "Read the current composer and preserve the owner's latest wording, links and account selection.", + "Record the intended card URL separately from supporting URLs; do not silently delete links.", + "Try placing the intended URL before all supporting URLs in the actual body, preferably near the opening. Record this as a mitigation pending published readback, not a proven fix. Merely typing it last is insufficient. Use scoped native editing and verify the editor is empty before inserting a replacement body.", + "Read back the complete final body. Wait for the card to resolve, then inspect its title, loaded image and target where exposed. Missing target evidence remains unknown; do not infer it from the body URL alone.", + "After any text or link edit, repeat card verification. If ambiguous, leave the draft unsent and report the gap.", + "Hand off at the requested manual-send boundary. After an authorized publication, inspect the actual post and card target. Where available, resolve card.rest_id through the long-post URL entities and check renderable card data; do not confuse note_tweet content with the truncated legacy body. A correct composer is preparation evidence only." + ], + "limits": [ + "URL order is an observed mitigation, not a stable X API contract or machine-enforced policy.", + "Cached metadata, removed previews and platform changes can produce different results.", + "This seed is a public distilled observation, not a raw chat, screenshot, account record or permission grant." + ], + "memory_initialization": { + "mode": "caller_reviewed_opt_in", + "adapter": "scoped_feedback", + "source_kind": "reviewed_learning_card", + "recall_surface_id": "agent_workflow.turn_admission", + "identity_key": ["goal_id", "agent_id", "seed_id", "version", "content_digest"], + "query_hint": "X browser draft preparation: multi-link card selection, rich-text replacement and final readback", + "instructions": [ + "Use the actor's explicitly configured corpus and provider; do not discover or reuse another Agent's private memory.", + "Review this seed against current artifacts and higher-priority instructions. Fill the existing scoped_feedback event with the configured scope, this content_summary and a seed/version/digest source reference.", + "Do not mark current_artifact_verified merely because the seed exists. Import as procedural_experience only after current verification through the existing candidate/review and ingest-event path.", + "Require the existing provider write and exact readback receipt before claiming initialization. Deduplicate the same scoped seed revision; review updates rather than overwriting newer local experience.", + "For automatic recall, configure the existing agent_workflow.turn_admission surface and include the actual X preparation work in the selected Todo. A configured provider alone does not connect a recall surface.", + "If memory is disabled or unavailable, read this bundled seed directly and report bundled guidance, not provider recall." + ], + "provider_calls_performed": false, + "automatic_import": false, + "grants_publish_authority": false + } +} diff --git a/loopx/capabilities/content_ops/social_browser_x.py b/loopx/capabilities/content_ops/social_browser_x.py index aced2b9ba2..5a42db7483 100644 --- a/loopx/capabilities/content_ops/social_browser_x.py +++ b/loopx/capabilities/content_ops/social_browser_x.py @@ -1,6 +1,9 @@ from __future__ import annotations +import hashlib +import json import shutil +from importlib.resources import files from typing import Any @@ -12,6 +15,14 @@ def build_social_browser_x_provider_packet() -> dict[str, Any]: + # Public packaged guidance, not a second memory store or an import side effect. + seed_bytes = ( + files("loopx.capabilities.content_ops") + .joinpath("experiences/x-composer-preflight-v1.json") + .read_bytes() + ) + experience = json.loads(seed_bytes) + experience["content_digest"] = "sha256:" + hashlib.sha256(seed_bytes).hexdigest() return { "ok": True, "schema_version": CONTENT_OPS_SOCIAL_BROWSER_X_PROVIDER_SCHEMA_VERSION, @@ -19,6 +30,7 @@ def build_social_browser_x_provider_packet() -> dict[str, Any]: "outcome_capability_id": "content-ops", "provider_module": SOCIAL_BROWSER_X_PROVIDER_MODULE, "source_profile": { + "operating_experience": experience, "connector_id": SOCIAL_BROWSER_X_CONNECTOR_ID, "status": "profile_ready_when_browser_available", "route_type": "browser-backed public/social channel", @@ -50,6 +62,7 @@ def build_social_browser_x_provider_packet() -> dict[str, Any]: ], }, "install_check": { + "operating_experience": experience, "connector_id": SOCIAL_BROWSER_X_CONNECTOR_ID, "status": "ready" if shutil.which("ego-browser") else "needs_ego_browser", "install": [ diff --git a/loopx/capabilities/value_connectors/install_check.py b/loopx/capabilities/value_connectors/install_check.py index ff35b6d0c9..e0ba7167c2 100644 --- a/loopx/capabilities/value_connectors/install_check.py +++ b/loopx/capabilities/value_connectors/install_check.py @@ -134,6 +134,14 @@ def render_value_connector_install_check_markdown(payload: dict[str, Any]) -> st ) if item.get("write_gate"): lines.append(f"- write_gate: {item.get('write_gate')}") + experience = item.get("operating_experience") + if isinstance(experience, Mapping): + lines.append( + f"- bundled experience `{experience.get('seed_id')}` v{experience.get('version')}: {experience.get('content_summary')}" + ) + lines.append( + "- Read operating_experience in the JSON packet before browser preparation; memory initialization remains caller-reviewed and opt-in." + ) lines.extend(["", "Install / use:"]) for command in item.get("install") or []: lines.append(f"- `{command}`") diff --git a/loopx/capabilities/value_connectors/source_map.py b/loopx/capabilities/value_connectors/source_map.py index 3b14decc1d..cdc4d48585 100644 --- a/loopx/capabilities/value_connectors/source_map.py +++ b/loopx/capabilities/value_connectors/source_map.py @@ -94,6 +94,7 @@ def _source_profile( maturity_hint: str, write_gate: str | None = None, stop_conditions: list[str] | None = None, + operating_experience: Mapping[str, Any] | None = None, ) -> dict[str, Any]: return { "schema_version": VALUE_CONNECTOR_SOURCE_PROFILE_SCHEMA_VERSION, @@ -121,6 +122,11 @@ def _source_profile( "requested action would perform an external write without an audit gate", ], **_outcome_provider_binding(connector_id), + **( + {"operating_experience": dict(operating_experience)} + if operating_experience is not None + else {} + ), } @@ -426,6 +432,14 @@ def render_value_connector_source_map_markdown(payload: dict[str, Any]) -> str: ) if profile.get("write_gate"): lines.append(f"- write_gate: {profile.get('write_gate')}") + experience = profile.get("operating_experience") + if isinstance(experience, Mapping): + lines.append( + f"- bundled experience `{experience.get('seed_id')}` v{experience.get('version')}: {experience.get('content_summary')}" + ) + lines.append( + "- Read operating_experience in the JSON packet for applicability and opt-in memory initialization; bundled guidance is not provider recall or publish authority." + ) lines.append("") action_gated = payload.get("action_gated_profiles") if isinstance(action_gated, list) and action_gated: diff --git a/pyproject.toml b/pyproject.toml index 85de0f0ad8..1844966d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ include = ["loopx*"] "loopx.control_plane.turn_driver" = ["*.ts"] "loopx.control_plane.work_items" = ["*.ts"] "*" = ["README.md", "README.zh-CN.md", "docs/*.md", "docs/protocols/*.md"] -"loopx.capabilities.content_ops" = ["templates/*.json"] +"loopx.capabilities.content_ops" = ["templates/*.json", "experiences/*.json"] "loopx.capabilities.manager_context" = ["skills/loopx-manager/SKILL.md"] "loopx.extensions.lark" = ["extension.toml", "README.md", "docs/*.md"] "loopx.extensions.openviking_periodic_report" = ["extension.toml"] diff --git a/tests/capabilities/test_value_connector_social_profile.py b/tests/capabilities/test_value_connector_social_profile.py index 974bbc4c57..bebd1adebb 100644 --- a/tests/capabilities/test_value_connector_social_profile.py +++ b/tests/capabilities/test_value_connector_social_profile.py @@ -1,5 +1,12 @@ from __future__ import annotations +import hashlib +import json +from importlib.resources import files + +from loopx.capabilities.reward_memory.scoped_feedback import ( + build_scoped_feedback_reward_memory_candidate, +) from loopx.capabilities.content_ops.social_browser_x import ( CONTENT_OPS_SOCIAL_BROWSER_X_PROVIDER_SCHEMA_VERSION, SOCIAL_BROWSER_X_PROVIDER_MODULE, @@ -9,9 +16,11 @@ from loopx.capabilities.catalog import BUILTIN_CAPABILITIES from loopx.capabilities.value_connectors.install_check import ( build_value_connector_install_check_packet, + render_value_connector_install_check_markdown, ) from loopx.capabilities.value_connectors.source_map import ( build_value_connector_source_map_packet, + render_value_connector_source_map_markdown, ) @@ -76,3 +85,99 @@ def test_content_ops_catalog_declares_social_provider() -> None: "module": SOCIAL_BROWSER_X_PROVIDER_MODULE, "doc": "loopx/capabilities/content_ops/README.md", } + + +def test_new_agent_discovers_packaged_advisory_seed_without_memory() -> None: + raw = ( + files("loopx.capabilities.content_ops") + .joinpath("experiences/x-composer-preflight-v1.json") + .read_bytes() + ) + seed = json.loads(raw) + source = build_value_connector_source_map_packet(connector="social_browser_x") + install = build_value_connector_install_check_packet(connector="social_browser_x") + surfaced = source["source_profiles"][0]["operating_experience"] + assert surfaced == seed | { + "content_digest": "sha256:" + hashlib.sha256(raw).hexdigest() + } + assert install["checks"][0]["operating_experience"] == surfaced + assert seed["authority"] == "advisory" + assert seed["target_class"] == "procedural_experience" + assert seed["version"] >= 1 + assert 0 < len(seed["content_summary"]) <= 500 + assert seed["applicability"] and seed["limits"] and seed["procedure"] + init = seed["memory_initialization"] + assert init["automatic_import"] is False + assert init["provider_calls_performed"] is False + assert init["grants_publish_authority"] is False + for packet, render in ( + (source, render_value_connector_source_map_markdown), + (install, render_value_connector_install_check_markdown), + ): + assert seed["content_summary"] in render(packet) + assert source["external_writes_performed"] is False + assert source["source_profiles"][0]["external_writes_allowed"] is False + # Non-social entry points retain their existing shape; no global hook. + for profile in build_value_connector_source_map_packet()["source_profiles"]: + if profile["connector_id"] != "social_browser_x": + assert "operating_experience" not in profile + surfaced["procedure"].clear() + assert build_social_browser_x_provider_packet()["source_profile"][ + "operating_experience" + ]["procedure"] + + +def test_seed_reuses_scoped_memory_guard_not_an_import_permission() -> None: + seed = build_social_browser_x_provider_packet()["source_profile"][ + "operating_experience" + ] + source_ref = ( + f"seed:{seed['seed_id']}:v{seed['version']}:{seed['content_digest'][-16:]}" + ) + event = { + "schema_version": "scoped_feedback_reward_memory_event_v0", + "feedback_ref": source_ref, + "workspace_ref": "workspace:synthetic", + "project_ref": "project:synthetic", + "peer_ref": "agent:synthetic", + "surface_id": seed["memory_initialization"]["recall_surface_id"], + "revision_ref": "revision:synthetic-verification", + "target_class": seed["target_class"], + "content_summary": seed["content_summary"], + "source": { + "source_kind": "reviewed_learning_card", + "source_ref": source_ref, + "actor_ref": "agent:synthetic", + "actor_role": "corpus_reviewer", + }, + "reasoning": { + "summary": "Synthetic current-artifact review fixture", + "confidence": "high", + }, + "guard_context": { + "source_freshness": "current", + "conflict_state": "clear", + "current_artifact_verified": False, + }, + "requested_action_scopes": [], + "raw_content_captured": False, + } + candidate = build_scoped_feedback_reward_memory_candidate(event)["shared_candidate"] + assert candidate["status"] == "guard_blocked" + event["guard_context"]["current_artifact_verified"] = True + candidate = build_scoped_feedback_reward_memory_candidate(event)["shared_candidate"] + assert candidate["status"] == "review_ready" + assert candidate["candidate"]["content_summary"] == seed["content_summary"] + assert candidate["provider_write_performed"] is False + assert candidate["external_writes_performed"] is False + assert ( + candidate + == build_scoped_feedback_reward_memory_candidate(event)["shared_candidate"] + ) + event["requested_action_scopes"] = ["publish"] + assert ( + build_scoped_feedback_reward_memory_candidate(event)["shared_candidate"][ + "status" + ] + == "guard_blocked" + )