diff --git a/README.md b/README.md index 3e47c635..360af6e4 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,48 @@ mcp_servers: args: ["alice-memory", "mcp", "--data-dir", "~/.alice"] ``` +#### OpenClaw + +OpenClaw reads `~/.openclaw/openclaw.json`, under `mcp.servers`: + +```json +{ + "mcp": { + "servers": { + "alice": { + "command": "uvx", + "args": ["alice-memory", "mcp", "--data-dir", "~/.alice"] + } + } + } +} +``` + +Or add it from the CLI, which probes the server before saving: + +```bash +openclaw mcp add alice --command uvx --arg alice-memory --arg mcp --arg --data-dir --arg ~/.alice +``` + +Confirm with `openclaw mcp probe`, which should report `alice: 11 tools`. OpenClaw +prefixes MCP tool names with the server name, so `alice_recall` reaches the model as +`alice__alice_recall`. + +#### Skill packs + +Optional, and useful once Alice is connected. [`agent-skills/`](https://github.com/samrusani/AliceMemory/tree/main/agent-skills) +holds a ready-made instruction pack for each host, telling the agent when to reach for +memory rather than leaving it to guess. Copy the directory, not the file: + +```bash +cp -R agent-skills/openclaw/alice-project-memory ~/.openclaw/skills/ +cp -R agent-skills/hermes/alice-memory ~/.hermes/skills/ +``` + +Both hosts load `/SKILL.md` and read the frontmatter `description` to decide +when the skill applies. A skill grants no tools on its own; it tells an agent how to use +the ones the MCP server already provides. + SQLite mode is the single-agent path and the one most agents should use: it serves the eleven core tools for one user, and memory review happens through `alice_memory_review` / `alice_memory_correct` instead of the web console. Boundaries are listed in [known limitations](https://github.com/samrusani/AliceMemory/blob/main/docs/alpha/known-limitations.md). > **Install note:** the PyPI package is [`alice-memory`](https://pypi.org/project/alice-memory/). The name `alice-core` on PyPI belongs to an unrelated project. diff --git a/agent-skills/hermes/alice-memory-skill.md b/agent-skills/hermes/alice-memory/SKILL.md similarity index 51% rename from agent-skills/hermes/alice-memory-skill.md rename to agent-skills/hermes/alice-memory/SKILL.md index c2bdbb11..2b8625de 100644 --- a/agent-skills/hermes/alice-memory-skill.md +++ b/agent-skills/hermes/alice-memory/SKILL.md @@ -1,3 +1,16 @@ +--- +name: alice-memory +description: Use Alice as the user's durable local memory. Load before answering from context, and whenever you learn something worth keeping across sessions. +version: 1.0.0 +author: Alice Memory +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [Memory, Continuity, MCP, Recall] + related_skills: [] +--- + # Hermes Alice Memory Skill Use Alice as the user's durable local memory and continuity layer. @@ -7,7 +20,9 @@ Default loop — one first call, then act, then write back: 1. Call `alice_context_pack` ONCE with a scoped query before planning, answering, or acting on important user context. The pack already carries memories, open loops, sources, contradictions, and honest gaps — do not stitch together raw searches first. 2. Act, treating `staleness` notes and `contradicting_evidence` as caution signals. 3. Call `alice_memory_commit` whenever you learn a durable fact worth keeping, including when the user has not asked you to remember it. It is the write verb for ordinary memory and what it records is immediately recallable. Use `alice_capture` for source documents and raw notes you want on record: it is review-gated, so `alice_recall` will not return it until a human reviews it. -4. Finish lifecycle work with `alice_memory_manage` (`confirm`/`undo`/`forget`) and track unresolved work with `alice_open_loops`. +4. Finish lifecycle work with `alice_memory_manage` (`confirm`/`undo`/`forget`). Record unresolved work with `alice_memory_commit` using `memory_type: "open_loop"`; `alice_open_loops` reads and closes loops, it does not create them. + +Your host may prefix these tool names with the server name. Read the names from the host's own tool list rather than assuming the bare form. Context depth (request field `context_depth`; deterministic retrieval, never model synthesis): `minimal` for single-fact checks (full-text only, max 4 memories, no sources/contradictions), `low` (default) for normal task context, `medium` for briefings and reviews (contradiction check on for every query type), `high` for audits and revision history (adds supersession chain notes). Explicit `include_sources`/`include_contradictions` override the tier default. The matching MCP tool arguments arrive in the same release — follow the server's `tools/list` schema. @@ -16,7 +31,7 @@ Rules: - never directly mutate trusted memory or the database - never bypass Alice policy - never request sensitive domains unless needed and allowed -- use `/vnext` review queues for human approval, audit, undo, correction, and forget flows +- use `alice_memory_review` and `alice_memory_correct` for human approval, audit, correction and forget flows Default identity: @@ -26,24 +41,26 @@ Default identity: Default scope is broad but policy-filtered. Avoid `health`, `family`, `spiritual`, `legal`, `financial`, and `regulated` unless the user explicitly enables that scope. -Good memory proposal: +Good ambient commit, nobody asked for this one: ```json -{"canonical_text":"The user prefers daily planning summaries with decisions, blockers, and next actions.","domain":"personal","sensitivity":"private","confidence":0.84} +{"title":"Preferred daily planning format","canonical_text":"The user prefers daily planning summaries with decisions, blockers, and next actions.","domain":"personal","sensitivity":"private","confidence":0.84} ``` -Good explicit commit: +Good explicit commit, the user said to remember it: ```json -{"agent_id":"hermes","permission_profile":"trusted_local_agent","intent":"explicit_remember","title":"Preferred daily planning format","canonical_text":"The user prefers daily planning summaries with decisions, blockers, and next actions.","domain":"personal","sensitivity":"private","confidence":0.93,"source_type":"direct_user_instruction"} +{"agent_id":"hermes","agent_type":"personal_assistant","permission_profile":"trusted_local_agent","title":"Preferred daily planning format","canonical_text":"The user prefers daily planning summaries with decisions, blockers, and next actions.","domain":"personal","sensitivity":"private","confidence":0.93,"source_type":"direct_user_instruction"} ``` -If Alice returns `confirmation_required`, show the proposed text and call `alice_vnext_confirm_memory` only after the user confirms. If Alice returns `review_required`, do not retry broadly; leave it for `/vnext` review. The `alice_vnext_*` MCP tools are on the legacy surface and require `ALICE_MCP_LEGACY_TOOLS=1` on the Alice MCP server. +`title` and `canonical_text` are the only required fields. Everything else is optional, and any field not in the server's `tools/list` schema is rejected outright rather than ignored. + +If Alice returns `confirmation_required`, show the proposed text and, only after the user confirms, call `alice_memory_manage` with `action: "confirm"` and the `confirmation_id` Alice returned. If Alice returns `review_required`, do not retry broadly; leave it for `alice_memory_review`. -Bad memory proposal: +Bad commit, too low confidence to be worth storing: ```json -{"canonical_text":"The user might dislike long reports.","confidence":0.31} +{"title":"Possible reporting preference","canonical_text":"The user might dislike long reports.","confidence":0.31} ``` See `docs/alpha/hermes-skill.md` for full recipes. diff --git a/agent-skills/openclaw/alice-project-memory-skill.md b/agent-skills/openclaw/alice-project-memory/SKILL.md similarity index 52% rename from agent-skills/openclaw/alice-project-memory-skill.md rename to agent-skills/openclaw/alice-project-memory/SKILL.md index 93e1ca6e..33e0cf1d 100644 --- a/agent-skills/openclaw/alice-project-memory-skill.md +++ b/agent-skills/openclaw/alice-project-memory/SKILL.md @@ -1,3 +1,12 @@ +--- +name: alice-project-memory +description: Use Alice as the project-scoped memory and continuity layer. Load before build or review work, and whenever a decision or constraint is worth keeping. +version: 1.0.0 +author: Alice Memory +license: MIT +platforms: [linux, macos, windows] +--- + # OpenClaw Alice Project Memory Skill Use Alice as the project-scoped memory and continuity layer. @@ -7,10 +16,12 @@ Default loop — one first call, then act, then write back: 1. Identify as OpenClaw. 2. Call `alice_context_pack` ONCE, project-scoped, before build or review work. The pack already carries decisions, procedures, open loops, sources, and contradictions — do not run raw searches first. 3. Perform the assigned build or review task, treating `staleness` notes and `contradicting_evidence` as caution signals. -4. Commit project-scoped memory via `alice_memory_commit` whenever you learn something worth keeping and the domain is `project`, including when the user has not asked you to remember it. It is the write verb for ordinary memory and what it records is immediately recallable. Use `alice_capture` for source documents, external evidence and raw notes: it is review-gated, so `alice_recall` will not return it until a human reviews it. Submit sprint outputs as reviewable agent outputs. -5. Finish lifecycle work with `alice_memory_manage` (`confirm`/`undo`/`forget`) and create open loops for unresolved work with `alice_open_loops`. +4. Commit project-scoped memory via `alice_memory_commit` whenever you learn something worth keeping and the domain is `project`, including when the user has not asked you to remember it. It is the write verb for ordinary memory and what it records is immediately recallable. Use `alice_capture` for source documents, external evidence, raw notes and generated sprint summaries: it is review-gated, so `alice_recall` will not return it until a human reviews it. +5. Finish lifecycle work with `alice_memory_manage` (`confirm`/`undo`/`forget`). Record unresolved work with `alice_memory_commit` using `memory_type: "open_loop"`; `alice_open_loops` reads and closes loops, it does not create them. 6. Do not access or write non-project personal domains. +Your host may prefix these tool names with the server name. In OpenClaw a server configured as `alice` exposes `alice_context_pack` as `alice__alice_context_pack`. Read the names from the host's own tool list rather than assuming the bare form. + Context depth (request field `context_depth`; deterministic retrieval, never model synthesis): `minimal` for quick fact checks (full-text only, max 4 memories, no sources/contradictions), `low` (default) for normal pre-task context, `medium` for reviews and status reports (contradiction check on for every query type), `high` for audits and revision history (adds supersession chain notes). Explicit `include_sources`/`include_contradictions` override the tier default. The matching MCP tool arguments arrive in the same release — follow the server's `tools/list` schema. Default identity: @@ -25,18 +36,20 @@ Context/read domains may include `project`, `professional`, and `system` when po Restricted by default: `personal`, `family`, `health`, `spiritual`, `legal`, `financial`, `regulated`. -Submit sprint output: +Submit a sprint output with `alice_capture`. It is review-gated, which is what you want for a generated summary; the field carrying the text is `raw_text`: ```json -{"agent_id":"openclaw","agent_type":"coding_agent","agent_run_id":"openclaw-sprint-001","task_id":"public-alpha-packaging","project_scope":["Alice"],"title":"OpenClaw sprint summary","content":"Decision: Agents use scoped context packs and review-only memory proposals.","output_type":"sprint_summary","domain":"project","sensitivity":"private","propose_memory":true} +{"agent_id":"openclaw","agent_type":"coding_agent","agent_run_id":"openclaw-sprint-001","task_id":"public-alpha-packaging","project_scope":["Alice"],"title":"OpenClaw sprint summary","raw_text":"Decision: Agents use scoped context packs and review-only memory proposals.","domain":"project","sensitivity":"private"} ``` -Explicit project memory commit: +Project memory commit: ```json -{"agent_id":"openclaw","agent_type":"coding_agent","permission_profile":"project_scoped_agent","project_scope":["Alice"],"intent":"explicit_remember","title":"Release gate decision","canonical_text":"Alice public alpha release gates require doctor, smokes, evals, and git diff checks before merge.","domain":"project","sensitivity":"private","confidence":0.94,"source_type":"direct_user_instruction"} +{"agent_id":"openclaw","agent_type":"coding_agent","permission_profile":"project_scoped_agent","project_scope":["Alice"],"title":"Release gate decision","canonical_text":"Alice public alpha release gates require doctor, smokes, evals, and git diff checks before merge.","domain":"project","sensitivity":"private","confidence":0.94,"source_type":"direct_user_instruction"} ``` -Use `alice_vnext_undo_memory`, `alice_vnext_correct_memory`, or `alice_vnext_forget_memory` through Alice if a committed project memory needs reversal or repair. Never edit Postgres directly. The `alice_vnext_*` MCP tools are on the legacy surface and require `ALICE_MCP_LEGACY_TOOLS=1` on the Alice MCP server. +`title` and `canonical_text` are the only required fields on a commit. Everything else is optional, and any field not in the server's `tools/list` schema is rejected outright rather than ignored. A `project_scoped_agent` must send `domain: "project"`, or the commit is rejected. + +Use `alice_memory_manage` for reversal or repair of a committed project memory: `action` of `undo`, `forget`, or `expire`, with the `memory_id` Alice returned. Never edit the database directly. See `docs/alpha/openclaw-skill.md` for full recipes. diff --git a/docs/alpha/agent-integration.md b/docs/alpha/agent-integration.md index 1d1c18a4..40009580 100644 --- a/docs/alpha/agent-integration.md +++ b/docs/alpha/agent-integration.md @@ -10,9 +10,9 @@ Agents should use Alice as a durable, private, provenance-aware, reviewable memo { "mcpServers": { "alice": { - "command": "/ABSOLUTE/PATH/TO/AliceBot/.venv/bin/python", + "command": "/ABSOLUTE/PATH/TO/AliceMemory/.venv/bin/python", "args": ["-m", "alicebot_api.mcp_server"], - "cwd": "/ABSOLUTE/PATH/TO/AliceBot", + "cwd": "/ABSOLUTE/PATH/TO/AliceMemory", "env": { "DATABASE_URL": "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot", "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001" diff --git a/docs/alpha/context-pack-recipes.md b/docs/alpha/context-pack-recipes.md index 2998d5a2..e2299b13 100644 --- a/docs/alpha/context-pack-recipes.md +++ b/docs/alpha/context-pack-recipes.md @@ -2,6 +2,10 @@ Every recipe uses scoped context and avoids raw unrestricted memory access. +These are `alice_context_pack` arguments. The scope fields are flat properties on the request, +not nested under `scope` and `options`; an unrecognised property is rejected outright rather +than ignored. + ## 1. Project Sprint Context - Purpose: prepare a coding agent for sprint work. @@ -9,7 +13,7 @@ Every recipe uses scoped context and avoids raw unrestricted memory access. - Permission: `project_scoped_agent` ```json -{"query":"current sprint decisions blockers architecture constraints","scope":{"domains":["project"],"projects":["Alice"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10}} +{"query":"current sprint decisions blockers architecture constraints","domains":["project"],"projects":["Alice"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10} ``` Next: build or review only within project scope. Do not request personal domains. @@ -17,7 +21,7 @@ Next: build or review only within project scope. Do not request personal domains ## 2. Code Review Context ```json -{"query":"recent changes review findings unresolved risks","scope":{"domains":["project"],"projects":["Alice"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":8}} +{"query":"recent changes review findings unresolved risks","domains":["project"],"projects":["Alice"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":8} ``` Next: cite findings and create open loops for unresolved issues. Do not promote review claims as trusted memory. @@ -25,7 +29,7 @@ Next: cite findings and create open loops for unresolved issues. Do not promote ## 3. Research Context ```json -{"query":"research notes decisions sources open questions","scope":{"domains":["project","professional"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":8}} +{"query":"research notes decisions sources open questions","domains":["project","professional"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":8} ``` Next: ingest report output. Do not include speculative claims as memory. @@ -33,7 +37,7 @@ Next: ingest report output. Do not include speculative claims as memory. ## 4. Daily Assistant Context ```json -{"query":"today priorities open loops recent decisions","scope":{"domains":["personal","professional","project"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":12}} +{"query":"today priorities open loops recent decisions","domains":["personal","professional","project"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":12} ``` Next: propose only stable preferences or durable decisions. @@ -41,7 +45,7 @@ Next: propose only stable preferences or durable decisions. ## 5. Meeting Preparation Context ```json -{"query":"meeting preparation stakeholders decisions open loops","scope":{"domains":["professional","project"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10}} +{"query":"meeting preparation stakeholders decisions open loops","domains":["professional","project"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10} ``` Next: generate a reviewable prep artifact. Do not request restricted domains unless needed. @@ -49,7 +53,7 @@ Next: generate a reviewable prep artifact. Do not request restricted domains unl ## 6. Investor Or Stakeholder Briefing Context ```json -{"query":"stakeholder briefing milestones risks decisions","scope":{"domains":["professional","project"]},"options":{"sensitivity_allowed":["public","internal","private"],"max_items":10}} +{"query":"stakeholder briefing milestones risks decisions","domains":["professional","project"],"sensitivity_allowed":["public","internal","private"],"max_items":10} ``` Next: produce a brief with source references. Do not include private personal data. @@ -57,7 +61,7 @@ Next: produce a brief with source references. Do not include private personal da ## 7. Recent Decisions Context ```json -{"query":"recent decisions","scope":{"domains":["project"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10}} +{"query":"recent decisions","domains":["project"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10} ``` Next: use decisions as constraints. Do not infer new decisions. @@ -65,7 +69,7 @@ Next: use decisions as constraints. Do not infer new decisions. ## 8. Recent Changes Context ```json -{"query":"recent changes since last sprint","scope":{"domains":["project"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10}} +{"query":"recent changes since last sprint","domains":["project"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10} ``` Next: summarize changes and submit output back to Alice. @@ -73,7 +77,7 @@ Next: summarize changes and submit output back to Alice. ## 9. Open Loops Context ```json -{"query":"open loops blockers waiting for follow ups","scope":{"domains":["project","professional"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10}} +{"query":"open loops blockers waiting for follow ups","domains":["project","professional"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":10} ``` Next: close only through review paths. Do not silently delete loops. @@ -81,7 +85,7 @@ Next: close only through review paths. Do not silently delete loops. ## 10. Contradiction Check ```json -{"query":"possible contradiction around current project direction","scope":{"domains":["project"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"include_contradictions":true,"max_items":8}} +{"query":"possible contradiction around current project direction","domains":["project"],"sensitivity_allowed":["public","internal","private","unknown"],"include_contradictions":true,"max_items":8} ``` Next: surface contradictions for review. Do not resolve without user confirmation. @@ -89,7 +93,7 @@ Next: surface contradictions for review. Do not resolve without user confirmatio ## 11. Long-running Task Resumption Context ```json -{"query":"resume long running task current state decisions blockers","scope":{"domains":["project"],"projects":["Alice"]},"options":{"sensitivity_allowed":["public","internal","private","unknown"],"max_items":12}} +{"query":"resume long running task current state decisions blockers","domains":["project"],"projects":["Alice"],"sensitivity_allowed":["public","internal","private","unknown"],"max_items":12} ``` Next: continue from cited context and create an output summary at the end. diff --git a/docs/alpha/hermes-skill.md b/docs/alpha/hermes-skill.md index efbf9c98..c92c4536 100644 --- a/docs/alpha/hermes-skill.md +++ b/docs/alpha/hermes-skill.md @@ -70,7 +70,6 @@ Good explicit commit: "agent_id": "hermes", "agent_type": "personal_assistant", "permission_profile": "trusted_local_agent", - "intent": "explicit_remember", "title": "Preferred daily planning format", "canonical_text": "The user prefers daily planning summaries with decisions, blockers, and next actions.", "domain": "personal", @@ -83,10 +82,16 @@ Good explicit commit: Expected outcomes: - `committed`: Alice stored the memory as active and auditable. -- `confirmation_required`: show the proposed text and call `alice_vnext_confirm_memory` only after the user confirms. -- `review_required`: leave the candidate in `/vnext` review. +- `confirmation_required`: show the proposed text and, only after the user confirms, call + `alice_memory_manage` with `action: "confirm"` and the returned `confirmation_id`. +- `review_required`: leave the candidate for `alice_memory_review`. - `rejected`: do not retry without narrowing scope or asking the user. +`title` and `canonical_text` are the only required fields. Every other property must appear in +the server's `tools/list` schema for the tool you are calling; an unrecognised property is +rejected outright rather than ignored. In particular `intent` exists only on the legacy +`alice_vnext_commit_memory` tool and is **not** accepted by `alice_memory_commit`. + Good proposal: ```json @@ -104,6 +109,7 @@ Bad proposal: ```json { + "title": "Possible reporting preference", "canonical_text": "The user might dislike long reports.", "confidence": 0.31, "rationale": "Speculative inference from one short reply." diff --git a/docs/alpha/mcp-tools.md b/docs/alpha/mcp-tools.md index e188bd95..884e1073 100644 --- a/docs/alpha/mcp-tools.md +++ b/docs/alpha/mcp-tools.md @@ -37,9 +37,9 @@ For the full Postgres stack from a checkout: { "mcpServers": { "alice": { - "command": "/ABSOLUTE/PATH/TO/AliceBot/.venv/bin/python", + "command": "/ABSOLUTE/PATH/TO/AliceMemory/.venv/bin/python", "args": ["-m", "alicebot_api.mcp_server"], - "cwd": "/ABSOLUTE/PATH/TO/AliceBot", + "cwd": "/ABSOLUTE/PATH/TO/AliceMemory", "env": { "DATABASE_URL": "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot", "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001" diff --git a/docs/alpha/memory-proposal-recipes.md b/docs/alpha/memory-proposal-recipes.md index 782ebd3f..3353beb6 100644 --- a/docs/alpha/memory-proposal-recipes.md +++ b/docs/alpha/memory-proposal-recipes.md @@ -24,7 +24,10 @@ Do not propose memory for: - sensitive personal content without clear relevance - transient task state -## API Shape +## Payload shape for `alice_memory_commit` + +`title` and `canonical_text` are required. Every other property must appear in the +server's `tools/list` schema for the tool; an unrecognised property is rejected outright. ```json { @@ -46,42 +49,42 @@ Do not propose memory for: ## Good Memory Proposal ```json -{"canonical_text":"OpenClaw should request project-scoped Alice context before coding tasks.","confidence":0.88,"domain":"project","sensitivity":"private","rationale":"Explicit integration rule."} +{"title":"OpenClaw requests project context first","canonical_text":"OpenClaw should request project-scoped Alice context before coding tasks.","confidence":0.88,"domain":"project","sensitivity":"private","rationale":"Explicit integration rule."} ``` ## Bad Memory Proposal ```json -{"canonical_text":"The user is probably frustrated with dashboards.","confidence":0.22,"rationale":"Speculative tone inference."} +{"title":"Possible dashboard frustration","canonical_text":"The user is probably frustrated with dashboards.","confidence":0.22,"rationale":"Speculative tone inference."} ``` ## Project Update Proposal ```json -{"proposal_type":"project_update","canonical_text":"The public preview packaging work is ready for onboarding after alpha-check passes.","domain":"project","sensitivity":"private","confidence":0.8} +{"title":"Preview packaging ready for onboarding","memory_type":"project_state","canonical_text":"The public preview packaging work is ready for onboarding after alpha-check passes.","domain":"project","sensitivity":"private","confidence":0.8} ``` ## Belief Update Proposal ```json -{"proposal_type":"belief_update","canonical_text":"Agent integration is the main adoption path if alpha feedback confirms agents rely on scoped context packs.","domain":"project","sensitivity":"private","confidence":0.74} +{"title":"Agent integration is the main adoption path","memory_type":"belief","canonical_text":"Agent integration is the main adoption path if alpha feedback confirms agents rely on scoped context packs.","domain":"project","sensitivity":"private","confidence":0.74} ``` ## Open-loop Proposal ```json -{"proposal_type":"open_loop","canonical_text":"Confirm who will run the first public preview install.","domain":"project","sensitivity":"private","confidence":0.76} +{"title":"Who runs the first preview install","memory_type":"open_loop","canonical_text":"Confirm who will run the first public preview install.","domain":"project","sensitivity":"private","confidence":0.76} ``` ## Contradiction Proposal ```json -{"proposal_type":"contradiction","canonical_text":"Resolve whether public preview should prioritize Gmail/Calendar connectors or agent skill hardening next.","domain":"project","sensitivity":"private","confidence":0.7} +{"title":"Connectors or skill hardening next","memory_type":"contradiction","canonical_text":"Resolve whether public preview should prioritize Gmail/Calendar connectors or agent skill hardening next.","domain":"project","sensitivity":"private","confidence":0.7} ``` Review behavior: -- proposals appear in `/vnext` Memory Review +- proposals appear in `alice_memory_review`, and are acted on with `alice_memory_correct` - confidence explains how strongly the agent believes the proposal - provenance links proposal to source or artifact evidence - trusted memory changes only after human review diff --git a/docs/alpha/openclaw-skill.md b/docs/alpha/openclaw-skill.md index e2bca6de..0bc48563 100644 --- a/docs/alpha/openclaw-skill.md +++ b/docs/alpha/openclaw-skill.md @@ -45,23 +45,21 @@ Context/read domains may include `project`, `professional`, and `system` when po Restricted by default: `personal`, `family`, `health`, `spiritual`, `legal`, `financial`, `regulated`. -Project context recipe: +Project context recipe. On the core `alice_context_pack` the scope fields are flat, not nested +under `scope` and `options`: ```json { "query": "current sprint decisions, architecture constraints, open loops", - "scope": { - "domains": ["project"], - "projects": ["Alice"] - }, - "options": { - "sensitivity_allowed": ["public", "internal", "private", "unknown"], - "max_items": 10 - } + "domains": ["project"], + "projects": ["Alice"], + "sensitivity_allowed": ["public", "internal", "private", "unknown"], + "max_items": 10 } ``` -Sprint output ingestion: +Sprint output, submitted through `alice_capture` so it lands review-gated. The field carrying +the text is `raw_text`: ```json { @@ -71,15 +69,13 @@ Sprint output ingestion: "task_id": "public-alpha-packaging", "project_scope": ["Alice"], "title": "OpenClaw sprint summary", - "content": "Decision: Public alpha agents use scoped context packs and review-only memory proposals.", - "output_type": "sprint_summary", + "raw_text": "Decision: Public alpha agents use scoped context packs and review-only memory proposals.", "domain": "project", - "sensitivity": "private", - "propose_memory": true + "sensitivity": "private" } ``` -Explicit project memory commit: +Project memory commit: ```json { @@ -87,7 +83,6 @@ Explicit project memory commit: "agent_type": "coding_agent", "permission_profile": "project_scoped_agent", "project_scope": ["Alice"], - "intent": "explicit_remember", "title": "Release gate decision", "canonical_text": "Alice public preview release gates require doctor, smokes, evals, and git diff checks before merge.", "domain": "project", @@ -97,7 +92,15 @@ Explicit project memory commit: } ``` -If Alice returns `review_required`, leave the item in `/vnext`. If Alice returns `rejected`, do not retry outside the `project` domain. Use Alice's undo, correct, or forget tools for repairs; never write directly to Postgres. +`title` and `canonical_text` are the only required fields. Every other property must appear in +the server's `tools/list` schema for the tool you are calling; an unrecognised property is +rejected outright rather than ignored. In particular `intent` exists only on the legacy +`alice_vnext_commit_memory` tool and is **not** accepted by `alice_memory_commit`. A +`project_scoped_agent` must send `domain: "project"`, or the commit is rejected. + +If Alice returns `review_required`, leave the item for `alice_memory_review`. If Alice returns +`rejected`, do not retry outside the `project` domain. Use `alice_memory_manage` with an +`action` of `undo`, `forget` or `expire` for repairs; never write directly to the database. Do propose memory for: diff --git a/docs/archive/process/public_alpha_packaging.md b/docs/archive/process/public_alpha_packaging.md index cdeeccf8..8d8d28d5 100644 --- a/docs/archive/process/public_alpha_packaging.md +++ b/docs/archive/process/public_alpha_packaging.md @@ -288,7 +288,7 @@ Create a ready-to-use Hermes skill/instruction file. Path suggestion: docs/alpha/hermes-skill.md -agent-skills/hermes/alice-memory-skill.md +agent-skills/hermes/alice-memory/SKILL.md Hermes should be instructed to use Alice as the user’s durable memory and continuity layer. @@ -335,7 +335,7 @@ Create a ready-to-use OpenClaw skill/instruction file. Path suggestion: docs/alpha/openclaw-skill.md -agent-skills/openclaw/alice-project-memory-skill.md +agent-skills/openclaw/alice-project-memory/SKILL.md OpenClaw should be project-scoped by default. diff --git a/scripts/run_single_tenant_deployment_smoke.py b/scripts/run_single_tenant_deployment_smoke.py index df242ce3..69c83cc7 100644 --- a/scripts/run_single_tenant_deployment_smoke.py +++ b/scripts/run_single_tenant_deployment_smoke.py @@ -475,8 +475,71 @@ def validate_role_separated_database_contract( ) +ALICE_UPSTREAMS = ("127.0.0.1:8000", "127.0.0.1:3000") + + +def _site_blocks(normalized: str) -> list[tuple[str, str]]: + """Split a normalized Caddyfile into (address line, body) site blocks. + + Brace-depth scan rather than a real parser, which is enough for this file's shape and + keeps the check dependency-free. The global options block opens with a bare `{` and is + returned with an empty address so callers can skip it. + """ + + blocks: list[tuple[str, str]] = [] + address: str | None = None + body: list[str] = [] + depth = 0 + for line in normalized.splitlines(): + stripped = line.strip() + if depth == 0: + if not stripped: + continue + if stripped.endswith("{"): + address = stripped[:-1].strip() + body = [] + depth = 1 + continue + depth += stripped.count("{") - stripped.count("}") + if depth <= 0: + blocks.append((address or "", "\n".join(body))) + address, body, depth = None, [], 0 + continue + body.append(line) + if address is not None: + blocks.append((address, "\n".join(body))) + return blocks + + +def _validate_caddy_block_scoping(normalized: str) -> None: + """Every block that can reach Alice must itself demand a client certificate. + + The rest of this validator is file-wide substring matching, which was sound only while + the example had exactly one site block. Once a second block exists (a public landing + page on the apex, say), a file-wide check proves "some block has mTLS", not "the block + serving Alice has mTLS". Reproduced 2026-08-15: stripping the tls/client_auth stanza + from the Alice block and leaving those strings in any other block passed every check, + so CI would go green on a configuration serving Alice to anyone. + """ + + reaches_alice = [ + (address, body) + for address, body in _site_blocks(normalized) + if address and any(f"reverse_proxy {upstream}" in body for upstream in ALICE_UPSTREAMS) + ] + _require(bool(reaches_alice), "caddy_api_upstream_invalid") + for address, body in reaches_alice: + _require("client_auth" in body, "caddy_authentication_missing") + _require("mode require_and_verify" in body, "caddy_mtls_not_fail_closed") + _require( + f"trust_pool file {CLIENT_CA_PATH}" in body, + "caddy_mtls_trust_pool_missing", + ) + + def validate_caddyfile(text: str) -> None: normalized = "\n".join(line.split("#", 1)[0].rstrip() for line in text.splitlines()) + _validate_caddy_block_scoping(normalized) directives = { tuple(line.split()) for line in normalized.splitlines() diff --git a/tests/unit/test_agent_facing_payloads_are_callable.py b/tests/unit/test_agent_facing_payloads_are_callable.py new file mode 100644 index 00000000..03803f16 --- /dev/null +++ b/tests/unit/test_agent_facing_payloads_are_callable.py @@ -0,0 +1,182 @@ +"""Every JSON payload we hand an agent must survive the server's own validator. + +Agents copy examples. That is what examples are for. So an example payload that the server +rejects is not a typo, it is a broken instruction shipped to a machine that will follow it. + +Found 2026-08-15 by executing the OpenClaw skill pack against the published v0.15.4 artifact +over stdio. Nine payloads across four files were rejected outright, because the skill packs and +their recipe docs named the CORE eleven-tool surface while still showing payloads shaped for +the legacy `alice_vnext_*` tools. The clearest case: every documented "explicit commit" carried +`intent: "explicit_remember"`, which exists only on `alice_vnext_commit_memory`. Removing that +one key was the difference between a hard failure and `status=committed`. + +This guard runs each documented payload through +`_validate_mcp_arguments_against_advertised_schema`, the same function the server calls before +any handler runs, against the tool the document presents it as being for. It deliberately +validates against tools on the DEFAULT surface: a payload that only works once an operator +sets ALICE_MCP_LEGACY_TOOLS=1 is not something to hand an agent unqualified. + +Adding a JSON block to one of these files without classifying it fails +`test_every_documented_payload_is_classified`. That is the point: someone must say which tool a +new example is for, and then it gets checked. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import re + +import pytest + +from alicebot_api.mcp.definitions import _CORE_TOOL_DEFINITIONS +from alicebot_api.mcp.registry import _validate_mcp_arguments_against_advertised_schema +from alicebot_api.mcp_tools import MCPToolError + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +CORE_TOOL_NAMES = frozenset(str(tool["name"]) for tool in _CORE_TOOL_DEFINITIONS) + +# Not an MCP tool payload. Host configuration, error shapes, and prose fragments. +NOT_A_TOOL_PAYLOAD = "n/a" + +# (relative path, block index) -> tool the document presents the payload as being for. +# Block index counts ```json fences in file order, from zero. +DOCUMENTED_PAYLOADS: dict[tuple[str, int], str] = { + ("agent-skills/hermes/alice-memory/SKILL.md", 0): "alice_memory_commit", + ("agent-skills/hermes/alice-memory/SKILL.md", 1): "alice_memory_commit", + ("agent-skills/hermes/alice-memory/SKILL.md", 2): "alice_memory_commit", + ("agent-skills/hermes/alice-memory/SKILL.md", 3): "alice_memory_commit", + ("agent-skills/openclaw/alice-project-memory/SKILL.md", 0): "alice_memory_commit", + ("agent-skills/openclaw/alice-project-memory/SKILL.md", 1): "alice_capture", + ("agent-skills/openclaw/alice-project-memory/SKILL.md", 2): "alice_memory_commit", + ("docs/alpha/hermes-skill.md", 0): "alice_memory_commit", + ("docs/alpha/hermes-skill.md", 1): "alice_memory_commit", + ("docs/alpha/hermes-skill.md", 2): "alice_memory_commit", + ("docs/alpha/hermes-skill.md", 3): "alice_memory_commit", + ("docs/alpha/openclaw-skill.md", 0): "alice_memory_commit", + ("docs/alpha/openclaw-skill.md", 1): "alice_context_pack", + ("docs/alpha/openclaw-skill.md", 2): "alice_capture", + ("docs/alpha/openclaw-skill.md", 3): "alice_memory_commit", + ("docs/alpha/mcp-tools.md", 0): NOT_A_TOOL_PAYLOAD, + ("docs/alpha/mcp-tools.md", 1): NOT_A_TOOL_PAYLOAD, + ("docs/alpha/mcp-tools.md", 2): NOT_A_TOOL_PAYLOAD, # response fragment, not a request + ("docs/alpha/agent-integration.md", 0): NOT_A_TOOL_PAYLOAD, + ("docs/alpha/agent-integration.md", 1): "alice_memory_commit", + ("docs/alpha/agent-integration.md", 2): NOT_A_TOOL_PAYLOAD, + ("docs/alpha/memory-proposal-recipes.md", 0): "alice_memory_commit", + ("docs/alpha/memory-proposal-recipes.md", 1): "alice_memory_commit", + ("docs/alpha/memory-proposal-recipes.md", 2): "alice_memory_commit", + ("docs/alpha/memory-proposal-recipes.md", 3): "alice_memory_commit", + ("docs/alpha/memory-proposal-recipes.md", 4): "alice_memory_commit", + ("docs/alpha/memory-proposal-recipes.md", 5): "alice_memory_commit", + ("docs/alpha/memory-proposal-recipes.md", 6): "alice_memory_commit", + **{("docs/alpha/context-pack-recipes.md", i): "alice_context_pack" for i in range(11)}, +} + +AGENT_FACING_FILES = sorted({path for path, _ in DOCUMENTED_PAYLOADS}) + +# The identity block every skill opens with is a fragment, not a whole call. It is checked for +# accepted properties but not for required ones, which the surrounding prose supplies. +IDENTITY_FRAGMENTS = { + ("agent-skills/hermes/alice-memory/SKILL.md", 0), + ("agent-skills/openclaw/alice-project-memory/SKILL.md", 0), + ("docs/alpha/hermes-skill.md", 0), + ("docs/alpha/openclaw-skill.md", 0), + ("docs/alpha/agent-integration.md", 1), +} + + +def _json_blocks(relative_path: str) -> list[tuple[int, object]]: + body = (REPO_ROOT / relative_path).read_text(encoding="utf-8") + blocks: list[tuple[int, object]] = [] + for index, raw in enumerate(re.findall(r"```json\n(.*?)```", body, re.S)): + try: + blocks.append((index, json.loads(raw))) + except json.JSONDecodeError: + blocks.append((index, None)) + return blocks + + +def _cases() -> list[tuple[str, int, str, dict]]: + cases = [] + for relative_path in AGENT_FACING_FILES: + for index, payload in _json_blocks(relative_path): + tool = DOCUMENTED_PAYLOADS.get((relative_path, index)) + if tool in (None, NOT_A_TOOL_PAYLOAD) or not isinstance(payload, dict): + continue + cases.append((relative_path, index, tool, payload)) + return cases + + +@pytest.mark.parametrize("relative_path", AGENT_FACING_FILES) +def test_every_documented_payload_is_classified(relative_path: str) -> None: + """A new example must be named before it can be checked.""" + + for index, payload in _json_blocks(relative_path): + assert (relative_path, index) in DOCUMENTED_PAYLOADS, ( + f"{relative_path} json block {index} is unclassified. Add it to " + f"DOCUMENTED_PAYLOADS naming the tool it targets, or {NOT_A_TOOL_PAYLOAD!r} if it " + f"is not an MCP tool payload. Keys: {sorted(payload) if isinstance(payload, dict) else payload}" + ) + + +def test_classification_table_has_no_stale_entries() -> None: + """Deleting a block must not leave a phantom entry that silently checks nothing.""" + + for relative_path in AGENT_FACING_FILES: + indices = {index for index, _ in _json_blocks(relative_path)} + declared = {index for path, index in DOCUMENTED_PAYLOADS if path == relative_path} + stale = declared - indices + assert not stale, f"{relative_path} declares blocks that no longer exist: {sorted(stale)}" + + +@pytest.mark.parametrize( + ("relative_path", "index", "tool", "payload"), + _cases(), + ids=lambda value: f"{value}" if isinstance(value, (str, int)) else "", +) +def test_documented_payload_targets_a_default_surface_tool( + relative_path: str, index: int, tool: str, payload: dict +) -> None: + assert tool in CORE_TOOL_NAMES, ( + f"{relative_path} block {index} targets {tool!r}, which is not on the default surface. " + "Agent-facing examples must run on a server started with no extra environment; a " + "legacy tool needs ALICE_MCP_LEGACY_TOOLS=1 and cannot be shown unqualified." + ) + + +@pytest.mark.parametrize( + ("relative_path", "index", "tool", "payload"), + _cases(), + ids=lambda value: f"{value}" if isinstance(value, (str, int)) else "", +) +def test_documented_payload_passes_the_servers_own_validator( + relative_path: str, index: int, tool: str, payload: dict +) -> None: + try: + _validate_mcp_arguments_against_advertised_schema(tool, payload) + except MCPToolError as exc: # pragma: no cover - the message is the whole point + pytest.fail( + f"{relative_path} json block {index} would be rejected by {tool}: {exc}\n" + f"payload keys: {sorted(payload)}" + ) + + +@pytest.mark.parametrize( + ("relative_path", "index", "tool", "payload"), + [case for case in _cases() if (case[0], case[1]) not in IDENTITY_FRAGMENTS], + ids=lambda value: f"{value}" if isinstance(value, (str, int)) else "", +) +def test_documented_payload_carries_every_required_property( + relative_path: str, index: int, tool: str, payload: dict +) -> None: + from alicebot_api.mcp.registry import _TOOL_DEFINITIONS_BY_NAME + + schema = (_TOOL_DEFINITIONS_BY_NAME[tool].get("inputSchema") or {}) + missing = set(schema.get("required") or []) - set(payload) + assert not missing, ( + f"{relative_path} json block {index} omits required {sorted(missing)} for {tool}. " + "An agent copying this example gets a hard failure." + ) diff --git a/tests/unit/test_agent_facing_write_verb_guidance.py b/tests/unit/test_agent_facing_write_verb_guidance.py index edfcc852..60a40a82 100644 --- a/tests/unit/test_agent_facing_write_verb_guidance.py +++ b/tests/unit/test_agent_facing_write_verb_guidance.py @@ -29,8 +29,8 @@ "docs/alpha/agent-integration.md", "docs/alpha/hermes-skill.md", "docs/alpha/openclaw-skill.md", - "agent-skills/hermes/alice-memory-skill.md", - "agent-skills/openclaw/alice-project-memory-skill.md", + "agent-skills/hermes/alice-memory/SKILL.md", + "agent-skills/openclaw/alice-project-memory/SKILL.md", ) # Wording retired in v0.15.4. Each of these makes an explicit user instruction @@ -86,8 +86,8 @@ def test_agent_facing_docs_do_not_gate_commits_on_being_asked(relative_path: str "docs/alpha/mcp-tools.md", "docs/alpha/hermes-skill.md", "docs/alpha/openclaw-skill.md", - "agent-skills/hermes/alice-memory-skill.md", - "agent-skills/openclaw/alice-project-memory-skill.md", + "agent-skills/hermes/alice-memory/SKILL.md", + "agent-skills/openclaw/alice-project-memory/SKILL.md", ), ) def test_agent_facing_docs_permit_the_unasked_commit(relative_path: str) -> None: @@ -101,8 +101,8 @@ def test_agent_facing_docs_permit_the_unasked_commit(relative_path: str) -> None "docs/alpha/mcp-tools.md", "docs/alpha/hermes-skill.md", "docs/alpha/openclaw-skill.md", - "agent-skills/hermes/alice-memory-skill.md", - "agent-skills/openclaw/alice-project-memory-skill.md", + "agent-skills/hermes/alice-memory/SKILL.md", + "agent-skills/openclaw/alice-project-memory/SKILL.md", ), ) def test_agent_facing_docs_warn_that_capture_is_not_recallable(relative_path: str) -> None: diff --git a/tests/unit/test_single_tenant_deployment.py b/tests/unit/test_single_tenant_deployment.py index bac0db1f..357e8d3e 100644 --- a/tests/unit/test_single_tenant_deployment.py +++ b/tests/unit/test_single_tenant_deployment.py @@ -454,6 +454,101 @@ def test_caddy_example_requires_mtls_and_preserves_real_client_ip() -> None: assert 'X-Frame-Options "DENY"' in caddyfile +_MTLS_STANZA = """\ttls { +\t\t# Install the public certificate of the operator CA that issues one +\t\t# client certificate per authorized browser or agent at this path. +\t\tclient_auth { +\t\t\tmode require_and_verify +\t\t\ttrust_pool file /etc/alicebot/client-ca.pem +\t\t} +\t} +""" + +_PUBLIC_LANDING_BLOCK = """ + +alicememory.com, www.alicememory.com { +\troot * /srv/landing +\tfile_server +} +""" + +_DECOY_MTLS_BLOCK = """ + +decoy.example.com { +\ttls { +\t\tclient_auth { +\t\t\tmode require_and_verify +\t\t\ttrust_pool file /etc/alicebot/client-ca.pem +\t\t} +\t} +} +""" + +_PUBLIC_BLOCK_PROXYING_TO_ALICE = """ + +alicememory.com { +\treverse_proxy 127.0.0.1:8000 +} +""" + + +def test_a_public_site_block_is_allowed_when_it_cannot_reach_alice() -> None: + """Adding a landing page on another hostname must not require weakening mTLS. + + This is the supported way to put a public page on the same host: a separate site + block with no `tls` directive and, critically, no `reverse_proxy` to Alice. + """ + + caddyfile = _asset(deployment.CADDY_RELATIVE_PATH) + + deployment.validate_caddyfile(caddyfile + _PUBLIC_LANDING_BLOCK) + + +@pytest.mark.parametrize( + ("mutation", "failure_code"), + ( + pytest.param( + lambda text: text.replace(_MTLS_STANZA, "") + _DECOY_MTLS_BLOCK, + "caddy_authentication_missing", + id="alice-block-loses-mtls-while-another-block-still-carries-the-strings", + ), + pytest.param( + lambda text: text + _PUBLIC_BLOCK_PROXYING_TO_ALICE, + "caddy_authentication_missing", + id="a-second-block-proxies-to-alice-without-demanding-a-certificate", + ), + ), +) +def test_caddy_mtls_assertions_are_scoped_to_the_block_that_serves_alice( + mutation, failure_code: str +) -> None: + """The file-wide checks proved "some block has mTLS", not "Alice's block has mTLS". + + Reproduced 2026-08-15 against the shipped validator: stripping the whole tls stanza + from the Alice block and leaving those strings anywhere else in the file passed every + check. The hole was harmless while the example had one site block and opens the moment + a second one is added, which is exactly what putting a landing page on the apex does. + """ + + caddyfile = _asset(deployment.CADDY_RELATIVE_PATH) + + with pytest.raises(deployment.DeploymentContractError) as excinfo: + deployment.validate_caddyfile(mutation(caddyfile)) + + assert str(excinfo.value) == failure_code + + +def test_the_mtls_stanza_constant_still_matches_the_shipped_example() -> None: + """Keeps the mutation above from silently becoming a no-op after a reformat.""" + + caddyfile = _asset(deployment.CADDY_RELATIVE_PATH) + + assert _MTLS_STANZA in caddyfile, ( + "the mTLS stanza was reformatted; update _MTLS_STANZA or the block-scoping " + "mutation tests stop removing anything and pass vacuously" + ) + + @pytest.mark.parametrize( ("mutation", "failure_code"), ( diff --git a/tests/unit/test_skill_packs_are_loadable.py b/tests/unit/test_skill_packs_are_loadable.py new file mode 100644 index 00000000..a4b90c6b --- /dev/null +++ b/tests/unit/test_skill_packs_are_loadable.py @@ -0,0 +1,134 @@ +"""The skill packs must be in the shape their hosts actually load. + +Verified against a real OpenClaw 2026.8.1 checkout on 2026-08-15 by putting three variants of +the same pack side by side in its managed skills directory and asking it what it loaded: + + DROPPED alice-project-memory-skill.md (the shape we had been shipping) + DROPPED SKILL.md, no frontmatter + LOADED SKILL.md + frontmatter modelVisible=true + +The drop is silent. A file whose name is not `SKILL.md` is never a candidate, and a file with +no frontmatter parses to an empty record with zero issues, so nothing is logged at any level. +We shipped two packs in the dropped shape and had no way to notice. + +Hermes uses the same convention: its own bundled skills are `/SKILL.md` with +`name` and `description` in YAML frontmatter. + +`description` is doubly load-bearing. The host puts only name and description in the system +prompt, so the model decides from the description alone whether to read the body. A pack that +loads but describes itself badly is invisible in practice. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +SKILL_PACK_ROOT = REPO_ROOT / "agent-skills" + +# Enough to be useful in a system prompt where it is the only thing the model sees. +MINIMUM_DESCRIPTION_LENGTH = 40 + + +def _skill_files() -> list[Path]: + return sorted(SKILL_PACK_ROOT.rglob("*.md")) + + +def _frontmatter(path: Path) -> dict[str, str] | None: + body = path.read_text(encoding="utf-8") + match = re.match(r"^---\n(.*?)\n---\n", body, re.S) + if not match: + return None + fields: dict[str, str] = {} + for line in match.group(1).splitlines(): + if line.startswith((" ", "\t")) or ":" not in line: + continue + key, _, value = line.partition(":") + fields[key.strip()] = value.strip().strip('"').strip("'") + return fields + + +def test_the_skill_pack_directory_is_not_empty() -> None: + """Guards against the whole check quietly becoming vacuous.""" + + assert _skill_files(), f"no skill packs found under {SKILL_PACK_ROOT}" + + +@pytest.mark.parametrize("path", _skill_files(), ids=lambda p: str(p.relative_to(REPO_ROOT))) +def test_every_shipped_skill_markdown_is_named_skill_md(path: Path) -> None: + assert path.name == "SKILL.md", ( + f"{path.relative_to(REPO_ROOT)} will never be loaded. Hosts join the literal filename " + "'SKILL.md' inside a skill directory; any other name is not a candidate and is dropped " + "with no warning. Move it to /SKILL.md." + ) + + +@pytest.mark.parametrize("path", _skill_files(), ids=lambda p: str(p.relative_to(REPO_ROOT))) +def test_every_shipped_skill_has_loadable_frontmatter(path: Path) -> None: + fields = _frontmatter(path) + assert fields is not None, ( + f"{path.relative_to(REPO_ROOT)} has no YAML frontmatter. Without it the host parses an " + "empty record, reports zero issues, and drops the skill silently." + ) + for required in ("name", "description"): + assert fields.get(required), ( + f"{path.relative_to(REPO_ROOT)} frontmatter is missing a non-empty {required!r}. " + "Both are required for the skill to load." + ) + + +@pytest.mark.parametrize("path", _skill_files(), ids=lambda p: str(p.relative_to(REPO_ROOT))) +def test_skill_description_is_substantial_enough_to_be_chosen(path: Path) -> None: + fields = _frontmatter(path) or {} + description = fields.get("description", "") + assert len(description) >= MINIMUM_DESCRIPTION_LENGTH, ( + f"{path.relative_to(REPO_ROOT)} description is {len(description)} chars. The host puts " + "only name and description in the system prompt, so the model decides from the " + "description alone whether to read the body. Say when to load it." + ) + + +@pytest.mark.parametrize("path", _skill_files(), ids=lambda p: str(p.relative_to(REPO_ROOT))) +def test_skill_directory_name_matches_the_declared_name(path: Path) -> None: + """`name` falls back to the directory basename, so a mismatch is a silent rename.""" + + fields = _frontmatter(path) or {} + assert fields.get("name") == path.parent.name, ( + f"{path.relative_to(REPO_ROOT)} declares name={fields.get('name')!r} but sits in " + f"{path.parent.name!r}. Hosts key skills by name and fall back to the directory " + "basename, so a mismatch makes the pack answer to two different identities." + ) + + +@pytest.mark.parametrize("path", _skill_files(), ids=lambda p: str(p.relative_to(REPO_ROOT))) +def test_skill_does_not_instruct_an_impossible_open_loop_create(path: Path) -> None: + """`alice_open_loops` reads and closes loops. It has never created one. + + The OpenClaw pack told agents to "create open loops for unresolved work with + alice_open_loops". Its action enum is list/close/edit/reopen/snooze, so every attempt + errors on the enum. + """ + + from alicebot_api.mcp.registry import _TOOL_DEFINITIONS_BY_NAME + + actions = set( + (_TOOL_DEFINITIONS_BY_NAME["alice_open_loops"]["inputSchema"]["properties"]["action"]) + .get("enum") + or [] + ) + assert "create" not in actions, ( + "alice_open_loops gained a create action; this guard and the skill packs should be " + "revisited together." + ) + + body = " ".join(path.read_text(encoding="utf-8").replace("`", "").split()).lower() + assert "create open loops" not in body, ( + f"{path.relative_to(REPO_ROOT)} tells an agent to create open loops with " + f"alice_open_loops, whose actions are {sorted(actions)}. Commit the item with " + "alice_memory_commit and memory_type 'open_loop' instead." + ) diff --git a/tests/unit/test_vnext_release_polish.py b/tests/unit/test_vnext_release_polish.py index 5856396d..c3088e48 100644 --- a/tests/unit/test_vnext_release_polish.py +++ b/tests/unit/test_vnext_release_polish.py @@ -90,8 +90,8 @@ def test_public_alpha_packaging_docs_and_commands_are_discoverable() -> None: troubleshooting = _read("docs/alpha/troubleshooting.md") release_notes = _read("docs/alpha/release-notes.md") cto_summary = _read("docs/archive/process/vnext-public-alpha-packaging-cto-summary.md") - hermes_copy = _read("agent-skills/hermes/alice-memory-skill.md") - openclaw_copy = _read("agent-skills/openclaw/alice-project-memory-skill.md") + hermes_copy = _read("agent-skills/hermes/alice-memory/SKILL.md") + openclaw_copy = _read("agent-skills/openclaw/alice-project-memory/SKILL.md") makefile = _read("Makefile") gitignore = _read(".gitignore")