diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 678e29f61a..6c6cf26158 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "powercontext", "source": "./integrations/claude-code/plugins/powercontext", "description": "Restore project memory and transfer current work from Claude Code", - "version": "0.1.2", + "version": "0.1.3", "category": "Productivity" } ] diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 6a8f1f84e4..350219b920 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "5c00096f12642263d6bc538f7271a1484e1f8a17660786b12563cac9f4d6b0ab", + "contractSha256": "7e1c301bf7f2ac51781280959efd0b2d92511ddeadee8314ba1683d956c93cc7", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 293abb3fd0..152bbede2b 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -558,6 +558,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/context/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Prepare bounded context for an Agent lifecycle boundary + * @description Prepare an opt-in, deterministic bootstrap package without manufacturing a query. Only explicitly curated active Atomic Memory Artifacts and one caller-selected exact committed Handoff are eligible. Returned historical text is untrusted data and grants no authority. A ready receipt remains pending until the host records delivery. Reusing one stable event identity with different package-shaping fields is rejected with 422. + */ + post: operations["prepare_bootstrap_context"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/context/bootstrap/receipts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Record a bootstrap delivery outcome + * @description Atomically claim one pending content-free receipt as injected or failed. A second injected claim is rejected with 422, so concurrent lifecycle hooks cannot both inject the same package. A terminal outcome never changes. + */ + post: operations["record_bootstrap_delivery"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/work/contracts/create": { parameters: { query?: never; @@ -3282,6 +3322,29 @@ export interface components { content: string | null; content_bytes: number; }; + BootstrapContextItem: { + kind: components["schemas"]["BootstrapContextItemKind"]; + scope_id: string; + artifact: components["schemas"]["ArtifactReference"]; + content_digest: string; + truncated: boolean; + }; + BootstrapDeliveryReceipt: { + receipt_id: string; + state: components["schemas"]["BootstrapReceiptState"]; + }; + BootstrapContext: { + schema: components["schemas"]["BootstrapContextSchema"]; + status: components["schemas"]["BootstrapContextStatus"]; + reason: components["schemas"]["BootstrapSkipReason"] | null; + profile: components["schemas"]["BootstrapContextProfile"]; + content: string | null; + content_bytes: number; + package_digest: string | null; + items: components["schemas"]["BootstrapContextItem"][]; + truncated: boolean; + receipt: components["schemas"]["BootstrapDeliveryReceipt"]; + }; EntryChange: { op: components["schemas"]["EntryChangeOperation"]; entry_id: string; @@ -3959,6 +4022,29 @@ export interface components { */ include_code: boolean; assembly?: components["schemas"]["ContextAssembly"]; + /** @description Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Atomic Memory revisions from an injected same-Scope receipt are removed before candidate limiting and sufficiency assessment; Artifacts whose bootstrap bodies were truncated remain eligible. */ + bootstrap_receipt_id?: string; + }; + BootstrapContextRequest: { + scope_id: string; + /** @default false */ + enabled: boolean; + /** @default powercontext.scope-bootstrap.v1 */ + profile: components["schemas"]["BootstrapContextProfile"]; + lifecycle: components["schemas"]["BootstrapContextLifecycle"]; + integration: string; + /** @description Optional stable host event identity, persisted only as a digest. Within the same Scope, integration, and lifecycle, retries must keep the profile, max_bytes, and exact Handoff unchanged. */ + event_id?: string; + /** @default 4096 */ + max_bytes: number; + /** @description Optional exact committed Handoff Revision in the current Scope. */ + handoff?: components["schemas"]["ArtifactReference"]; + }; + RecordBootstrapDeliveryRequest: { + scope_id: string; + receipt_id: string; + /** @enum {string} */ + outcome: "injected" | "failed"; }; ContextAssemblySection: { family: components["schemas"]["ContextAssemblyFamily"]; @@ -4512,6 +4598,20 @@ export interface components { /** @enum {string} */ PreparedContextStatus: "ready" | "empty"; /** @enum {string} */ + BootstrapContextSchema: "powercontext.bootstrap-context.v1"; + /** @enum {string} */ + BootstrapContextProfile: "powercontext.scope-bootstrap.v1"; + /** @enum {string} */ + BootstrapContextLifecycle: "startup" | "resume" | "clear" | "compact" | "restore" | "fork"; + /** @enum {string} */ + BootstrapContextStatus: "ready" | "empty" | "skipped"; + /** @enum {string} */ + BootstrapContextItemKind: "atomic-memory" | "handoff"; + /** @enum {string} */ + BootstrapReceiptState: "pending" | "injected" | "skipped" | "failed"; + /** @enum {string} */ + BootstrapSkipReason: "disabled" | "no_eligible_context" | "already_delivered" | "preparation_failed"; + /** @enum {string} */ EntryChangeOperation: "add" | "revise" | "deactivate" | "reactivate" | "compact"; /** @enum {string} */ FlushStatus: "idle" | "processed"; @@ -5929,6 +6029,66 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + prepare_bootstrap_context: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BootstrapContextRequest"]; + }; + }; + responses: { + /** @description A bounded package ready for host injection, or a normal empty/skipped result. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapContext"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + record_bootstrap_delivery: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordBootstrapDeliveryRequest"]; + }; + }; + responses: { + /** @description Current durable delivery state. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapDeliveryReceipt"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; create_work_contract: { parameters: { query?: never; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index f387adbfea..933ed4bdee 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "5c00096f12642263d6bc538f7271a1484e1f8a17660786b12563cac9f4d6b0ab"; +export const contractSha256 = "7e1c301bf7f2ac51781280959efd0b2d92511ddeadee8314ba1683d956c93cc7"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/integrations/claude-code.md b/docs/en/docs/integrations/claude-code.md index 2374d159e6..b55619936d 100644 --- a/docs/en/docs/integrations/claude-code.md +++ b/docs/en/docs/integrations/claude-code.md @@ -56,7 +56,7 @@ powercontext doctor claude-code claude ``` -Use `/hooks` to confirm the `UserPromptSubmit` Hook and `/mcp` to confirm the `powercontext` Server. +Use `/hooks` to confirm the `SessionStart` and `UserPromptSubmit` Hooks and `/mcp` to confirm the `powercontext` Server. These are separate capture/recall and explicit-tool connections; both must work. `doctor claude-code` primarily checks installation state. Complete the [Source and Topic check](../get-started/quickstart.md#4-verify-topic-memory-with-ordinary-conversation) to verify memory behavior. @@ -82,6 +82,34 @@ For each user prompt, the Hook: 3. strictly validates `powercontext.prepared-context.v1` and injects it unchanged through `additionalContext`; 4. independently captures the prompt as ordinary Content Source evidence. +Bootstrap context is a separate, opt-in `SessionStart` path. Enable it before launching Claude Code: + +```bash +export POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT=true +claude +``` + +On `startup`, `resume`, `clear`, `compact`, and `fork`, the fixed `powercontext.scope-bootstrap.v1` profile can inject +at most six active Atomic Memory Artifacts explicitly tagged `bootstrap-context`, considering the current Scope before +at most eight direct Context References. It never scans all Memory, Sources, transcripts, pending Candidates, or +generated instructions; secret-like Memory kinds are excluded. The content is untrusted history and cannot override +current instructions. + +The default 4096-byte budget can be set from 512 through 8192 bytes with +`POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES`. No Handoff is selected automatically. Select one exact committed Handoff +from the current Scope only when it is intended for the new Session: + +```bash +export POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revision":3}' +``` + +Use the Atomic Memory Artifact tag API to add `bootstrap-context` only to reviewed decisions, constraints, objectives, +verified state, or repository guidance. The plugin stores a content-free receipt under `${CLAUDE_PLUGIN_DATA}`; +successful delivery lets the first ordinary query suppress only fully delivered exact Atomic Memory revisions. +Artifacts truncated during bootstrap and newer revisions remain eligible. Preparation, validation, and receipt failures +fail open and inject nothing. Failures before a new lifecycle package is established, including invalid configuration, +also clear any stale saved receipt. + The Source pipeline may later extract Memory when a generation model is configured. Prompt capture does not call `remember_memory`, and the Hook never labels an ordinary prompt as `task-outcome`. @@ -222,6 +250,9 @@ run with `--keep-data`. | `POWERCONTEXT_CLAUDE_ALLOW_INSECURE_HTTP` | `false` | Explicitly permit non-loopback plaintext HTTP for PowerContext requests | | `POWERCONTEXT_CLAUDE_SCOPE_ID` | unset | Override durable bindings and the Server default Scope | | `POWERCONTEXT_CLAUDE_AUTHORIZATION` | unset | Complete `Bearer ` header for Hook and MCP requests | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT` | `false` | Inject the curated bootstrap profile on supported `SessionStart` events | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` | `4096` | Bootstrap package limit; valid values are 512 through 8192 bytes | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF` | unset | JSON identity of one exact committed Handoff, for example `{"artifact_id":"...","revision":3}` | | `POWERCONTEXT_CLAUDE_CAPTURE_PROMPTS` | `true` | Capture user prompts as ordinary Source evidence | | `POWERCONTEXT_CLAUDE_FLUSH_ON_CAPTURE` | `false` | Wait for Source processing after capture | | `POWERCONTEXT_CLAUDE_REQUEST_TIMEOUT_SECONDS` | `3` | Per-request Hook timeout | diff --git a/docs/en/docs/integrations/codex.md b/docs/en/docs/integrations/codex.md index 4ca47efdc4..e616e0a0e8 100644 --- a/docs/en/docs/integrations/codex.md +++ b/docs/en/docs/integrations/codex.md @@ -30,7 +30,7 @@ Conflicting file and process declarations still require an explicit choice; use runtime URL overrides before rerunning setup. `--source` selects plugin code, not the Server endpoint. Open a new Codex session after setup. Use `/hooks` to inspect and, when prompted, trust the PowerContext -`UserPromptSubmit` hook. +`SessionStart` and `UserPromptSubmit` hooks. `powercontext doctor codex --json` reports basic MCP connectivity as `mcp_tools` and complete tool coverage as `mcp_full_profile`. Full coverage requires every capability-bearing tool in the Codex integration's `server-mcp` @@ -101,6 +101,43 @@ another task, session, or model. It must be explicitly prepared, inspected, and a few Memory entries. Read [Memory and Handoff](../workflows/memory-and-handoff.md) for the boundary and [Hand off work in Codex](../workflows/handoff-with-codex.md) for the procedure. +## Opt in to Session bootstrap context + +Bootstrap context is off by default. To load a small, deterministic package before Codex's first prompt on +`startup`, `resume`, `clear`, or `compact`, start Codex with: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT=true +codex +``` + +The fixed `powercontext.scope-bootstrap.v1` profile includes at most six active Atomic Memory Artifacts explicitly +tagged `bootstrap-context`, considering the current Scope before at most eight direct Context References. It never +scans all Memory, Sources, transcripts, pending Candidates, or generated instructions. Secret-like Memory kinds are +excluded. Historical text is marked as untrusted and cannot override current user, developer, system, or repository +instructions. + +The default package limit is 4096 UTF-8 bytes. It can be set from 512 through 8192 bytes: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES=6144 +``` + +No Handoff is selected automatically. To add one exact committed Handoff from the current Scope, provide its immutable +identity before starting Codex: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revision":3}' +``` + +Use the Atomic Memory Artifact tag API to add `bootstrap-context` only to reviewed decisions, constraints, objectives, +verified state, or repository guidance suitable for every new Session. A content-free delivery receipt makes retries +idempotent; after successful injection, the first ordinary query sends that receipt so the Runtime suppresses only +fully delivered exact Atomic Memory revisions. Artifacts truncated during bootstrap and newer revisions remain eligible +for recall. +Preparation, validation, and receipt failures all fail open and inject nothing. +Failures before a new lifecycle package is established, including invalid configuration, also clear any stale saved receipt. + ## Experience and Skill Alongside Memory, Source capture, context injection, @@ -286,6 +323,9 @@ Complete the [Source, topic evolution, and cross-session recall check](../get-st | `POWERCONTEXT_CODEX_ALLOW_INSECURE_HTTP` | `false` | Explicitly permit non-loopback plaintext HTTP for hooks | | `POWERCONTEXT_CODEX_SCOPE_ID` | unset | Explicitly select an existing Scope instead of resolving bindings and the Server default | | `POWERCONTEXT_CODEX_AUTHORIZATION` | unset | Complete `Bearer ` runtime override; setup saves it for subsequent Hook and native MCP connections | +| `POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT` | `false` | Inject the curated bootstrap profile on supported `SessionStart` events | +| `POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES` | `4096` | Bootstrap package limit; valid values are 512 through 8192 bytes | +| `POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF` | unset | JSON identity of one exact committed Handoff, for example `{"artifact_id":"...","revision":3}` | | `POWERCONTEXT_CODEX_CAPTURE_PROMPTS` | `true` | Capture user prompts as Source evidence | | `POWERCONTEXT_CODEX_FLUSH_ON_CAPTURE` | `false` | Wait for Source processing after capture | | `POWERCONTEXT_CODEX_REQUEST_TIMEOUT_SECONDS` | `3` | Per-request hook timeout | diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index fb5457c358..33f6d93a83 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -260,7 +260,8 @@ so the previous database remains available for recovery: exported `pc_scopes` data. If the source predates the three Skill lifecycle tables (`pc_skill_packages`, `pc_agent_skill_targets`, and `pc_skill_publications`), the Profile tables, `pc_topic_memory_work_budgets`, `pc_recall_effort_daily`, - `pc_receipt_migration_review`, or `pc_decision_observations`, remove the absent tables from their respective layers. + `pc_receipt_migration_review`, `pc_context_bootstrap_receipts`, or `pc_decision_observations`, remove the absent + tables from their respective layers. When a work-budget table exists, restore it together with Cursors so failure allowances survive the migration. Layer 1 contains parents and tables without foreign keys: @@ -275,7 +276,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_dream_runs,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ + --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ -f ``` diff --git a/docs/en/rfcs/1429_project_bootstrap_context.md b/docs/en/rfcs/1429_project_bootstrap_context.md new file mode 100644 index 0000000000..e642f21ede --- /dev/null +++ b/docs/en/rfcs/1429_project_bootstrap_context.md @@ -0,0 +1,70 @@ +# RFC 1429: Bounded Scope Bootstrap Context + +- Status: Implemented +- Issue: [#1429](https://github.com/oceanbase/powercontext/issues/1429) +- Related RFCs: [Scope organization and Agent integration](1345_scope_organization_and_agent_integration.md), [Memory layer](0014_memory_layer_design.md), [Handoff Artifact](0048_handoff_artifact.md), and [Artifact tags](1467_artifact_tags.md) + +## Summary + +PowerContext may provide a small, explicitly curated context package before the first real query of a supported Agent lifecycle. The package is opt-in, deterministic, byte-bounded, cited to immutable content, treated as untrusted history, and delivered at most once for a host-provided stable lifecycle identity. Delivery failure never prevents the Agent from starting. + +Issue #1429 used the product terms Project and Workstream. RFC 1345 subsequently made Scope the only ownership and routing boundary and direct Context References the authorized read set. This RFC therefore implements the requested behavior for one resolved current Scope plus its direct Context References; it does not reintroduce Project or Workstream catalogs. + +## Goals + +- Prepare useful context at `startup`, `resume`, `clear`, `compact`, `restore`, or `fork` without manufacturing a search query. +- Include only reviewed, explicitly selected material and preserve exact authoritative references and content digests. +- Bound work, item count, per-item text, and final UTF-8 bytes without model inference. +- Persist content-free delivery receipts with `pending`, `injected`, `skipped`, or `failed` state. +- Let the first ordinary query identify the last injected receipt so identical Atomic Memory revisions are not returned twice. +- Support both Codex and Claude Code through their maintained `SessionStart` hooks. + +## Non-goals + +Bootstrap is not full Memory recall, a transcript summary, a new instruction tier, or a replacement for `prepare_context`. It does not create or infer a Scope binding. It does not read raw Sources, pending candidates, generated Profile drafts, arbitrary Artifact families, environment variables, repository files, or conversation transcripts. + +## Eligibility profile + +The initial profile is `powercontext.scope-bootstrap.v1`. + +Eligible content is deliberately narrow: + +1. Up to six active current Atomic Memory Artifacts explicitly tagged `bootstrap-context`. The current Scope is considered first, followed by at most eight direct Context References in canonical Scope order. At most 32 tagged Artifacts are inspected per participating Scope. +2. At most one committed Handoff Revision supplied by the caller as an exact `{family, artifact_id, revision}` reference in the current Scope. “Latest Handoff” is never selected implicitly. + +The `bootstrap-context` tag is an operator review decision, not an authorization mechanism. Operators must not tag credentials, secrets, personal data, generated instructions that have not been reviewed, or material that should not be injected automatically. The Runtime additionally rejects entries whose normalized kind identifies secret, credential, password, token, API-key, access-key, or private-key material, including punctuation and separator variants. Current authorization is still checked for every participating Scope. + +The package renderer includes a prominent untrusted-history policy. Current system and developer instructions, the current user request, repository rules, and live validation always take precedence. Historical Markdown is rendered as quoted data rather than executable instructions. + +## HTTP contract + +`POST /v1/context/bootstrap` accepts the resolved `scope_id`, opt-in `enabled`, profile, lifecycle, integration name, optional stable `event_id`, byte limit, and optional exact Handoff reference. The default is disabled. It returns `powercontext.bootstrap-context.v1` with `ready`, `empty`, or `skipped` status, the bounded content when ready, its digest, exact item references and digests, truncation information, and a delivery receipt. + +A ready package has a `pending` receipt. Before emitting hook output, the host calls `POST /v1/context/bootstrap/receipts` with `injected` or `failed`. A host must claim the `pending`→`injected` transition successfully before writing `additionalContext`; a second `injected` claim is rejected even if the stored state is already `injected`. If the claim fails, the host emits no bootstrap content. Disabled and empty requests produce `skipped` receipts; a terminal event retry also returns `skipped` without a body. + +Receipts store no query, content body, transcript path, raw host event identifier, or host error text. They store only bounded identity metadata, exact content references and digests, byte counts, creation time, and state. A supplied event identifier is hashed with the Scope, integration, and lifecycle before persistence. Package-shaping fields are separately fingerprinted: reusing the same stable event with a different profile, byte budget, or exact Handoff is rejected instead of returning a package built for another request. Repeating an otherwise identical stable event returns the existing result while pending and returns `skipped` after a terminal outcome. If opt-in is revoked while an event is pending, that receipt is terminally skipped without reading Context References. Receipts expire after 30 days and are pruned opportunistically. Disabled or empty invocations without a stable event receive an ephemeral response receipt but do not create a database row. Hosts that expose no stable event identity cannot claim restart idempotency. + +`PrepareContextRequest` optionally accepts `bootstrap_receipt_id`. When that receipt belongs to the same Scope and is `injected`, ordinary recall removes only fully delivered exact Atomic Memory revisions listed by the receipt. This happens before candidate limiting and recall-sufficiency assessment, so delivered Artifacts neither satisfy the gate nor displace eligible replacements. Atomic Memory identities are independent, so unrelated writes cannot change the delivered identity. A partially delivered Artifact and any newer revision remain eligible. An absent, stale, foreign, pending, skipped, or failed receipt is ignored so recall remains fail-open. + +## Rendering and bounds + +The default final limit is 4,096 bytes and the hard maximum is 8,192 bytes. There are at most seven items: one exact Handoff followed by six curated Atomic Memory Artifacts. Each item body is capped at 2,000 bytes and truncated only at Unicode character boundaries. Complete Scope, Artifact Revision, digest, and truncation metadata remain visible. Items that cannot retain a meaningful body and their citation within the remaining budget are omitted. + +Selection uses exact tag lookup and immutable reads only. It does not embed, rerank, generate, or expand. The participating Scope count, tagged-entry scan, selected item count, per-item text, and final output are all bounded. Ordering and rendering are deterministic for the same exact inputs. + +## Host behavior + +Codex and Claude Code resolve Scope through their existing binding service, then request bootstrap context from `SessionStart`. Their supported lifecycle sources stay distinct in the request and receipt. Both integrations are disabled by default, enforce one wall-clock HTTP budget, validate the complete response schema, persist only the latest receipt ID in the host's plugin data directory, retain an already-saved matching receipt when the same terminal injected event is repeated, and fail open on invalid configuration, timeout, authentication failure, unavailable or older servers, and invalid responses. A consumed or cleared receipt is never recreated from terminal server state. Any failure before a new lifecycle package is established clears a stale local receipt. + +The subsequent `UserPromptSubmit` uses normal query-based recall and passes the saved receipt ID solely as an exact-version deduplication hint. Prompt capture behavior is unchanged. + +## Security and observability + +- Bootstrap never expands Scope authorization. The current Scope and every direct Context Reference considered by the bounded profile require `scope.read`. +- Package text is historical data, not authority. Exact citations make every included claim inspectable. +- Logs and diagnostics use outcome codes and counts only. They do not include package bodies, queries, identifiers supplied by the host, or errors returned by external systems. +- Database unavailability, malformed historical content, hook timeouts, and unsupported server versions all degrade to no injected context. + +## Acceptance + +Implementation is complete when contract and runtime tests cover opt-in defaults, all lifecycle values, exact Handoff selection, curated Atomic Memory ordering, Unicode byte bounds, normalized secret-kind exclusion, authorization across Context References, receipt retry, request-mismatch and transition semantics, bounded retention, package-level omissions, pre-gate first-query exact-revision deduplication across unrelated Atomic Memories, continued recall of partially delivered Artifacts and newer revisions, and content-free storage; and both Codex and Claude Code tests cover successful injection, disabled/empty paths, repeated terminal events, state handoff to the first query, invalid configuration and responses, timeouts, and HTTP failures without blocking the host. diff --git a/docs/zh/docs/integrations/claude-code.md b/docs/zh/docs/integrations/claude-code.md index 6ef956be26..ecadff0b91 100644 --- a/docs/zh/docs/integrations/claude-code.md +++ b/docs/zh/docs/integrations/claude-code.md @@ -52,7 +52,7 @@ powercontext doctor claude-code claude ``` -使用 `/hooks` 确认 `UserPromptSubmit` Hook,使用 `/mcp` 确认 `powercontext` Server。 +使用 `/hooks` 确认 `SessionStart` 和 `UserPromptSubmit` Hook,使用 `/mcp` 确认 `powercontext` Server。 两者分别负责采集/召回和显式工具调用,必须都连接成功。`doctor claude-code` 主要检查安装状态, 完整记忆还需完成[Source 与主题验收](../get-started/quickstart.md#4-用普通对话验收-topic-memory)。 @@ -77,6 +77,30 @@ MCP endpoint 来自 setup 保存的 `server_url`,Hook 可被 `POWERCONTEXT_CLA 3. 严格校验 `powercontext.prepared-context.v1`,再通过 `additionalContext` 原样注入; 4. 独立地将 prompt 采集为普通 Content Source 证据。 +启动上下文是独立且默认关闭的 `SessionStart` 路径。在启动 Claude Code 前显式启用: + +```bash +export POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT=true +claude +``` + +在 `startup`、`resume`、`clear`、`compact` 和 `fork` 事件中,固定 profile +`powercontext.scope-bootstrap.v1` 最多注入 6 个显式标记为 `bootstrap-context` 的 active Atomic Memory Artifact;当前 Scope +优先,其次是最多 8 个直接 Context References。它不会扫描全部 Memory、Source、transcript、pending Candidate 或生成的 +instruction,也会排除类型类似 secret 的 Memory。内容始终是不可信历史,不能覆盖当前指令。 + +默认预算为 4096 bytes,可通过 `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` 在 512 到 8192 之间调整。系统不会 +自动选择 Handoff;只有确实要交给新 Session 时,才显式指定当前 Scope 中一个 exact committed Handoff: + +```bash +export POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revision":3}' +``` + +通过 Atomic Memory Artifact tag API,只把经过审阅的决策、约束、目标、已验证状态或仓库指南标记为 `bootstrap-context`。 +插件在 `${CLAUDE_PLUGIN_DATA}` 下只保存不含正文的 receipt;成功注入后,第一条普通查询仅排除正文已完整交付的 +exact Atomic Memory revisions;启动时被截短的 Artifact 和后续修订版本仍可召回。准备、校验或回执失败时都会 fail open, +不注入任何内容。在新的生命周期包建立前发生失败(包括配置非法)时,还会清除之前保存的过期 receipt。 + 配置 generation model 后,Source pipeline 可能进一步提取 Memory。提示词采集不会调用 `remember_memory`, Hook 也不会把普通 prompt 标记为 `task-outcome`。 @@ -207,6 +231,9 @@ claude plugin marketplace remove powercontext | `POWERCONTEXT_CLAUDE_ALLOW_INSECURE_HTTP` | `false` | 显式允许 PowerContext 请求使用非环回明文 HTTP | | `POWERCONTEXT_CLAUDE_SCOPE_ID` | 未设置 | 覆盖持久 binding 和 Server 默认 Scope | | `POWERCONTEXT_CLAUDE_AUTHORIZATION` | 未设置 | Hook 与 MCP 请求使用的完整 `Bearer ` header | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT` | `false` | 在支持的 `SessionStart` 事件中注入精选启动 profile | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` | `4096` | 启动上下文总预算;有效范围为 512 到 8192 bytes | +| `POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF` | 未设置 | 一个 exact committed Handoff 的 JSON 身份,例如 `{"artifact_id":"...","revision":3}` | | `POWERCONTEXT_CLAUDE_CAPTURE_PROMPTS` | `true` | 把用户 prompt 采集为普通 Source 证据 | | `POWERCONTEXT_CLAUDE_FLUSH_ON_CAPTURE` | `false` | 采集后等待 Source 处理 | | `POWERCONTEXT_CLAUDE_REQUEST_TIMEOUT_SECONDS` | `3` | Hook 单次请求超时 | diff --git a/docs/zh/docs/integrations/codex.md b/docs/zh/docs/integrations/codex.md index 8311d1998d..a61dbebcd5 100644 --- a/docs/zh/docs/integrations/codex.md +++ b/docs/zh/docs/integrations/codex.md @@ -28,7 +28,8 @@ powercontext doctor codex 未提供新地址时,setup 复用保存的地址。文件与进程中声明的地址冲突时,仍需显式选择:使用 `--server-url`, 并在重新执行前统一或清除运行时地址覆盖。`--source` 指定插件代码来源,不指定 Server 地址。 -配置完成后开启新的 Codex 会话。通过 `/hooks` 查看 PowerContext `UserPromptSubmit` Hook,并在收到提示时授予信任。 +配置完成后开启新的 Codex 会话。通过 `/hooks` 查看 PowerContext `SessionStart` 和 `UserPromptSubmit` Hook, +并在收到提示时授予信任。 `powercontext doctor codex --json` 通过 `mcp_tools` 报告基础 MCP 连接,通过 `mcp_full_profile` 报告完整工具覆盖。 完整覆盖要求 Codex 集成 manifest 的 `server-mcp` 中声明的全部能力工具,包括 Memory 维护、Scope 管理和 @@ -85,6 +86,40 @@ Codex 开始分析提示词前,Hook 只调用一次 `POST /v1/context/prepare` Memory 用于长期保存可复用的决策、约束和状态;Handoff 用于临时移交当前任务,不能用几条 Memory 替代。概念边界见 [理解 Memory 和 Handoff](../workflows/memory-and-handoff.md),操作步骤见[在 Codex 中交接工作](../workflows/handoff-with-codex.md)。 +## 选择启用 Session 启动上下文 + +启动上下文默认关闭。若要在 Codex 的 `startup`、`resume`、`clear` 或 `compact` 事件中,于第一条 prompt 前加载一份 +小而确定的上下文包,请用以下环境启动 Codex: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT=true +codex +``` + +固定 profile `powercontext.scope-bootstrap.v1` 最多包含 6 个显式标记为 `bootstrap-context` 的 active Atomic Memory +Artifact;顺序是当前 Scope 优先,其次是最多 8 个直接 Context References。它不会扫描全部 Memory、Source、 +transcript、pending Candidate 或生成的 instruction,也会排除类型类似 secret 的 Memory。所有历史文本都标记为 +不可信,不能覆盖当前的用户、developer、system 或仓库指令。 + +默认总预算是 4096 UTF-8 bytes,可在 512 到 8192 之间调整: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES=6144 +``` + +系统不会自动选择 Handoff。若要加入当前 Scope 中一个 exact committed Handoff,请在启动 Codex 前提供其不可变身份: + +```bash +export POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF='{"artifact_id":"HANDOFF_ID","revision":3}' +``` + +通过 Atomic Memory Artifact tag API,只把经过审阅、适合每个新 Session 的决策、约束、目标、已验证状态或 +仓库指南标记为 `bootstrap-context`。不含正文的 delivery receipt 让重试保持幂等;成功注入后,第一条普通查询 +会携带该 receipt,Runtime 仅排除正文已完整交付的 exact Atomic Memory revisions;启动时被截短的 Artifact 和 +后续修订版本仍可正常召回。 +准备、校验或回执失败时都会 fail open,不注入任何内容。 +在新的生命周期包建立前发生失败(包括配置非法)时,还会清除之前保存的过期 receipt。 + ## Experience 和 Skill 能力 除 Memory、Source 采集、上下文注入、Work Contract、Handoff、确认、Task Outcome 和候选审查外,MCP 还提供以下工具: @@ -256,6 +291,9 @@ MCP 显示 connected 也不等于 Source 已采集。 | `POWERCONTEXT_CODEX_ALLOW_INSECURE_HTTP` | `false` | 显式允许 Hook 使用非环回明文 HTTP | | `POWERCONTEXT_CODEX_SCOPE_ID` | 未设置 | 显式选择一个已存在 Scope,不再解析 binding 和 Server 默认 Scope | | `POWERCONTEXT_CODEX_AUTHORIZATION` | 未设置 | 完整 `Bearer ` 运行时覆盖;setup 保存后供后续 Hook 和原生 MCP 连接使用 | +| `POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT` | `false` | 在支持的 `SessionStart` 事件中注入精选启动 profile | +| `POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES` | `4096` | 启动上下文总预算;有效范围为 512 到 8192 bytes | +| `POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF` | 未设置 | 一个 exact committed Handoff 的 JSON 身份,例如 `{"artifact_id":"...","revision":3}` | | `POWERCONTEXT_CODEX_CAPTURE_PROMPTS` | `true` | 把用户提示词采集为 Source 证据 | | `POWERCONTEXT_CODEX_FLUSH_ON_CAPTURE` | `false` | 采集后等待 Source 处理 | | `POWERCONTEXT_CODEX_REQUEST_TIMEOUT_SECONDS` | `3` | Hook 单次请求超时 | diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index a664e93bc6..fbf9c58414 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -251,7 +251,8 @@ collation,但不会包含数据库 URL 或凭据。 后代记录之前。 源数据库早于三张 Skill 生命周期表(`pc_skill_packages`、`pc_agent_skill_targets` 和 `pc_skill_publications`)、Profile 表、`pc_topic_memory_work_budgets`、`pc_recall_effort_daily`、 - `pc_receipt_migration_review` 或 `pc_decision_observations` 时,应从对应层删除缺失的表。 + `pc_receipt_migration_review`、`pc_context_bootstrap_receipts` 或 `pc_decision_observations` 时,应从对应层 + 删除缺失的表。 如果存在 work-budget 表,必须与 Cursor 一起恢复,保留已消耗的失败额度,避免迁移后重置累计成本。 第 1 层包含父表和无外键的表: @@ -266,7 +267,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_dream_runs,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ + --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_artifact_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ -f ``` diff --git a/docs/zh/rfcs/1429_project_bootstrap_context.md b/docs/zh/rfcs/1429_project_bootstrap_context.md new file mode 100644 index 0000000000..58e420740c --- /dev/null +++ b/docs/zh/rfcs/1429_project_bootstrap_context.md @@ -0,0 +1,48 @@ +# RFC 1429:有界 Scope 启动上下文 + +- 状态:已实现 +- Issue:[#1429](https://github.com/oceanbase/powercontext/issues/1429) +- 相关 RFC:[Scope 组织与 Agent 集成](1345_scope_organization_and_agent_integration.md)、[Memory 层](0014_memory_layer_design.md)、[Handoff Artifact](0048_handoff_artifact.md)、[Artifact 标签](1467_artifact_tags.md) + +## 摘要 + +PowerContext 可以在受支持的 Agent 生命周期第一次真实查询之前,提供一个小型、显式策展的上下文包。该能力默认关闭;输出确定、按 UTF-8 字节限额、引用不可变内容,并始终作为不可信历史处理。当宿主提供稳定生命周期标识时,同一边界最多交付一次;任何失败都不得阻止 Agent 启动。 + +Issue #1429 使用了 Project 和 Workstream 术语。后续 RFC 1345 已确定 Scope 是唯一的所有权与路由边界,直接 Context Reference 是授权读取集合。因此本 RFC 在一个已解析的当前 Scope 及其直接 Context Reference 上实现需求,不重新引入 Project 或 Workstream 目录。 + +## 内容资格 + +首个画像版本为 `powercontext.scope-bootstrap.v1`,只允许: + +1. 最多六个当前有效、且被显式标记 `bootstrap-context` 的 Atomic Memory Artifact。先选择当前 Scope,再按 Scope 的规范顺序选择最多八个直接 Context Reference;每个参与 Scope 最多检查 32 个带标签的 Artifact。 +2. 调用方在当前 Scope 中显式指定的一条精确、已提交 Handoff Revision。服务端不会隐式选择“最新 Handoff”。 + +标签表示运维者已经审核其适合自动注入,但不是授权机制。不得标记凭证、秘密、个人数据、未经审核的生成指令或其他不应自动注入的内容。Runtime 还会先归一化标点和分隔符,再排除 kind 表明为 secret、credential、password、token、API-key、access-key 或 private-key 的条目。每个参与 Scope 仍须通过当前授权检查。 + +完整 transcript、全部 Memory、待审核候选、原始 Source 窗口、生成中的 Profile 以及任意其他 Artifact Family 均不符合资格。选择过程不调用 embedding、rerank、生成或扩展。 + +## API 与交付收据 + +`POST /v1/context/bootstrap` 接收已解析的 `scope_id`、显式开关、画像、生命周期、集成名称、可选稳定 `event_id`、字节上限和可选精确 Handoff 引用。返回 `powercontext.bootstrap-context.v1`,状态为 `ready`、`empty` 或 `skipped`,并携带有界正文、包摘要、精确引用、截断信息和交付收据。 + +`ready` 响应的收据为 `pending`。宿主必须先通过 `POST /v1/context/bootstrap/receipts` 一次性认领 `pending`→`injected` 状态迁移,成功后才能输出 `additionalContext`;第二次认领即使已存状态是 `injected` 也会被拒绝。确认失败时不注入。宿主也可记录 `failed`。关闭和无合格内容会产生 `skipped` 收据;终态事件重试只返回不含正文的 `skipped` 响应。 + +收据不保存查询、正文、transcript 路径、原始宿主事件 ID 或宿主错误文本,只保存有界身份元数据、精确内容引用与摘要、字节数、创建时间和状态。事件 ID 在持久化前与 Scope、集成及生命周期一同哈希;画像、字节预算和精确 Handoff 等会影响包内容的字段另行计算指纹。用不同的这些字段复用同一稳定事件会被拒绝,而不会误返回为另一请求生成的包。其余字段相同的稳定事件重试在 `pending` 时重建同一精确包,终态后返回 `skipped`;若待交付期间关闭开关,收据会直接终结为跳过且不读取 Context Reference。收据保留 30 天并在后续请求中顺带清理;无稳定事件标识的关闭或空结果只返回临时收据,不新增数据库行。宿主不提供稳定事件标识时,不宣称具备跨重启幂等性。 + +`PrepareContextRequest` 可携带 `bootstrap_receipt_id`。只有当收据属于相同 Scope 且状态为 `injected` 时,普通查询召回才去掉收据中完整交付的精确 Atomic Memory Revision。过滤发生在候选限额和召回充分性判断之前,因此已交付 Artifact 既不会误让 gate 判定充分,也不会挤掉仍合格的候选。Atomic Memory 身份彼此独立,其他写入不会改变已交付身份;正文只交付了前缀的 Artifact 及后续修订版仍可召回。缺失、过期、跨 Scope 或非注入收据均被忽略,保持 fail-open。 + +## 限额与信任边界 + +默认输出 4,096 字节,硬上限 8,192 字节;最多一条 Handoff 加六个 Atomic Memory Artifact。参与 Scope 数量、带标签 Artifact 扫描数、选中项数、单项正文和最终输出均有固定上限。单项正文最多 2,000 字节,只在 Unicode 字符边界截断,Scope、Artifact Revision、内容摘要及截断标志必须完整保留。无法在剩余预算中同时保留有意义正文与引用的项目会被省略。 + +输出明确声明为不可信历史数据。当前 system/developer 指令、用户请求、仓库规则和实时验证始终优先;历史 Markdown 以引用数据呈现,不成为新的指令层。 + +## 宿主集成与失败策略 + +Codex 和 Claude Code 复用现有 Scope 绑定,在 `SessionStart` 请求启动包,并保留 `startup`、`resume`、`clear`、`compact`、`restore` 和 `fork` 的独立来源语义。两端均默认关闭,使用统一的绝对 HTTP 时间预算,严格校验响应,只在插件持久数据目录保存最近的收据 ID;同一已注入终态事件重放时仅保留本地已经存在且匹配的收据,已经消费或清除的收据不会根据服务端终态重建;配置非法、超时、鉴权失败、无权限、服务不可用、旧版本服务或非法响应均不阻断宿主。在新生命周期包建立前发生的任何失败都会清除过期的本地收据。失败与空结果使用不含正文的诊断事件区分。 + +随后第一次 `UserPromptSubmit` 仍执行普通基于查询的召回,只把该收据 ID 作为精确版本去重提示;现有 prompt 捕获行为不变。 + +## 验收 + +契约与 Runtime 测试必须覆盖:默认关闭、全部生命周期、精确 Handoff、策展 Atomic Memory 顺序、Unicode 字节限额、分隔符归一化后的秘密 kind 排除、Context Reference 授权、收据重试、请求冲突、状态迁移与有界保留、包级遗漏、在 gate 前完成的跨无关 Atomic Memory 首查询精确 Revision 去重、部分交付 Artifact 和后续修订版继续可召回,以及数据库不保存正文。Codex 与 Claude Code 测试必须覆盖成功注入、关闭/空结果、终态事件重放、向首查询传递收据、非法配置与响应、超时和 HTTP 失败均不阻塞宿主。 diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index d47a49206a..ff71c0b069 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -217,7 +217,7 @@ id = "prompt-hooks" probe = "json_prompt_hook" [[toolsets.tools]] id = "codex:SessionStart" -capabilities = ["persistent_scope_binding"] +capabilities = ["context_injection", "persistent_scope_binding"] [[toolsets.tools]] id = "codex:PreToolUse" capabilities = ["persistent_scope_binding"] @@ -228,6 +228,9 @@ capabilities = ["source_capture", "context_injection", "flush_or_checkpoint"] id = "codex:Stop" non_profile_reason = "Reports scoped recall-token estimates after completed turns." [[toolsets.tools]] +id = "claude-code:SessionStart" +capabilities = ["context_injection"] +[[toolsets.tools]] id = "claude-code:UserPromptSubmit" capabilities = ["source_capture", "context_injection", "flush_or_checkpoint"] [[toolsets.tools]] @@ -773,9 +776,9 @@ capabilities = ["memory_read", "memory_write", "source_capture", "context_inject profiles = ["minimal", "recommended", "full"] toolsets = ["server-mcp", "prompt-hooks"] [integrations.evidence] -implementation = ["integrations/codex/plugins/powercontext/.mcp.json", "integrations/codex/plugins/powercontext/hooks/recall.py"] +implementation = ["integrations/codex/plugins/powercontext/.mcp.json", "integrations/codex/plugins/powercontext/hooks/session_binding.py", "integrations/codex/plugins/powercontext/hooks/recall.py"] documentation = ["docs/en/docs/integrations/codex.md", "docs/zh/docs/integrations/codex.md"] -tests = ["tests/codex_plugin/test_contract.py", "tests/e2e/test_codex_service_chain.py", "tests/e2e/test_mcp_full_profile.py"] +tests = ["tests/codex_plugin/test_bootstrap.py", "tests/codex_plugin/test_contract.py", "tests/e2e/test_bootstrap_host_service_chain.py", "tests/e2e/test_codex_service_chain.py", "tests/e2e/test_mcp_full_profile.py"] [[integrations]] id = "claude-code" @@ -785,9 +788,9 @@ capabilities = ["memory_read", "memory_write", "source_capture", "context_inject profiles = ["minimal", "recommended", "full"] toolsets = ["server-mcp", "prompt-hooks"] [integrations.evidence] -implementation = ["integrations/claude-code/plugins/powercontext/.mcp.json", "integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py"] +implementation = ["integrations/claude-code/plugins/powercontext/.mcp.json", "integrations/claude-code/plugins/powercontext/hooks/session_start.py", "integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py"] documentation = ["docs/en/docs/integrations/claude-code.md", "docs/zh/docs/integrations/claude-code.md"] -tests = ["tests/claude_code_plugin/test_contract.py", "tests/e2e/test_claude_code_service_chain.py"] +tests = ["tests/claude_code_plugin/test_bootstrap.py", "tests/claude_code_plugin/test_contract.py", "tests/e2e/test_bootstrap_host_service_chain.py", "tests/e2e/test_claude_code_service_chain.py"] [[integrations]] id = "dsh" diff --git a/integrations/claude-code/plugins/powercontext/.claude-plugin/plugin.json b/integrations/claude-code/plugins/powercontext/.claude-plugin/plugin.json index c740564a68..cf061339d3 100644 --- a/integrations/claude-code/plugins/powercontext/.claude-plugin/plugin.json +++ b/integrations/claude-code/plugins/powercontext/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "powercontext", - "version": "0.1.2", + "version": "0.1.3", "description": "Restore project memory and transfer current work through PowerContext.", "author": { "name": "PowerContext Team", diff --git a/integrations/claude-code/plugins/powercontext/README.md b/integrations/claude-code/plugins/powercontext/README.md index 11794f9831..8c1eefc709 100644 --- a/integrations/claude-code/plugins/powercontext/README.md +++ b/integrations/claude-code/plugins/powercontext/README.md @@ -16,6 +16,18 @@ Automatic recall and prompt capture run on `UserPromptSubmit`. The plugin never reads the Claude Code transcript or captures Claude's final response in v1. Prompt Sources are evidence and are never marked as `task-outcome` by the hook. +An opt-in `SessionStart` hook can inject one bounded curated package before the +first prompt on startup, resume, clear, compact, or fork. Enable it with +`POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT=true`. Only active Atomic Memory +Artifacts tagged `bootstrap-context` and one explicitly selected exact committed Handoff are +eligible. `POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES` controls the 512–8192-byte +budget, and `POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF` supplies the Handoff JSON +identity. The plugin persists only a content-free delivery receipt. The first +ordinary recall suppresses fully delivered exact Atomic Memory revisions while +leaving Artifacts truncated during bootstrap eligible. +Invalid configuration or another failure before a new package is established +clears any stale saved receipt and injects nothing. + Scope is resolved by the Server from an explicit override, the current session or workspace binding, and finally the Server default. Bindings let multiple agents share the same Scope without deriving identities locally. diff --git a/integrations/claude-code/plugins/powercontext/bootstrap_state.py b/integrations/claude-code/plugins/powercontext/bootstrap_state.py new file mode 100644 index 0000000000..00f4d1308d --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/bootstrap_state.py @@ -0,0 +1,171 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Persist only a hashed Session key and the latest bootstrap receipt identity.""" + +from __future__ import annotations + +import json +import os +import stat +from collections.abc import Iterator +from contextlib import contextmanager, suppress +from hashlib import sha256 +from pathlib import Path +from uuid import uuid4 + + +def load_receipt(session_id: str | None, scope_id: str) -> str | None: + state = load_receipt_state(session_id) + return state[1] if state is not None and state[0] == scope_id else None + + +def load_receipt_state(session_id: str | None) -> tuple[str, str] | None: + path = _state_path(session_id) + if path is None: + return None + return _read_receipt_state(path) + + +def _read_receipt_state(path: Path) -> tuple[str, str] | None: + try: + if path.is_symlink() or not path.is_file() or (os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077): + return None + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or set(value) != {"version", "scope_id", "receipt_id"}: + return None + scope_id = value.get("scope_id") + receipt_id = value.get("receipt_id") + if ( + value.get("version") != 1 + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(receipt_id, str) + or not 1 <= len(receipt_id) <= 64 + or not all("\x21" <= character <= "\x7e" for character in receipt_id) + ): + return None + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + return scope_id, receipt_id + + +def save_receipt(session_id: str | None, scope_id: str, receipt_id: str) -> None: + path = _state_path(session_id) + if path is None: + raise OSError + root = path.parent + root.mkdir(mode=0o700, parents=True, exist_ok=True) + if root.is_symlink() or not root.is_dir(): + raise OSError + if os.name != "nt": + root.chmod(0o700) + payload = json.dumps( + {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, + separators=(",", ":"), + ).encode() + with _locked(path): + temporary = root / f".{path.name}.{uuid4().hex}.tmp" + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(temporary, flags, 0o600) + try: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + os.close(descriptor) + with suppress(FileNotFoundError): + temporary.unlink() + + +def clear_receipt(session_id: str | None) -> None: + path = _state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if not path.is_symlink(): + path.unlink(missing_ok=True) + except OSError: + return + + +def clear_receipt_if_matches( + session_id: str | None, + scope_id: str, + receipt_id: str, +) -> None: + path = _state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if _read_receipt_state(path) == (scope_id, receipt_id): + path.unlink(missing_ok=True) + except OSError: + return + + +@contextmanager +def _locked(path: Path) -> Iterator[None]: + lock_path = path.with_name(f".{path.name}.lock") + with lock_path.open("a+b") as lock_file: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +def _state_path(session_id: str | None) -> Path | None: + if session_id is None or not session_id.strip(): + return None + configured = os.environ.get("POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR") + plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA") + if configured is None and plugin_data is None: + return None + root = Path(configured or plugin_data or "").expanduser() + if not root.is_absolute(): + return None + digest = sha256(session_id.strip().encode()).hexdigest() + return root / "powercontext-bootstrap" / f"{digest}.json" diff --git a/integrations/claude-code/plugins/powercontext/claude_code_settings.py b/integrations/claude-code/plugins/powercontext/claude_code_settings.py index 5bc3641d03..7d2b7d6c30 100644 --- a/integrations/claude-code/plugins/powercontext/claude_code_settings.py +++ b/integrations/claude-code/plugins/powercontext/claude_code_settings.py @@ -45,6 +45,32 @@ def _is_loopback_host(host: str) -> bool: return False +@dataclass(frozen=True, slots=True) +class BootstrapHandoff: + """One explicitly selected exact committed Handoff.""" + + artifact_id: str + revision: int + family: str = "handoff" + + def __post_init__(self) -> None: + if ( + self.family != "handoff" + or not self.artifact_id + or self.artifact_id != self.artifact_id.strip() + or len(self.artifact_id) > 128 + or not self.artifact_id.isascii() + or not self.artifact_id.isprintable() + or not isinstance(self.revision, int) + or isinstance(self.revision, bool) + or self.revision < 1 + ): + raise ValueError("invalid bootstrap Handoff identity") # noqa: TRY003 + + def as_request(self) -> dict[str, object]: + return {"family": self.family, "artifact_id": self.artifact_id, "revision": self.revision} + + @dataclass(frozen=True, slots=True) class ClaudeCodePluginSettings: """Configuration loaded once by a plugin entry point.""" @@ -53,6 +79,9 @@ class ClaudeCodePluginSettings: authorization: str | None = None scope_id: str | None = None context_assembly: dict[str, object] | None = None + bootstrap_context: bool = False + bootstrap_max_bytes: int = 4096 + bootstrap_handoff: BootstrapHandoff | None = None include_code: bool = False capture_prompts: bool = True flush_on_capture: bool = False @@ -82,6 +111,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "scope_id", _optional_text(self.scope_id)) if self.request_timeout_seconds <= 0 or self.http_budget_seconds <= 0: raise ValueError("PowerContext HTTP timeouts must be positive") # noqa: TRY003 + if not 512 <= self.bootstrap_max_bytes <= 8192: + raise ValueError("PowerContext bootstrap_max_bytes must be between 512 and 8192") # noqa: TRY003 if not 1 <= self.flush_max_calls <= 16: raise ValueError("PowerContext flush_max_calls must be between 1 and 16") # noqa: TRY003 @@ -112,6 +143,15 @@ def from_environment(cls, *, server_url: str | None = None) -> ClaudeCodePluginS ), scope_id=_first_environment("POWERCONTEXT_CLAUDE_SCOPE_ID"), context_assembly=_environment_object("POWERCONTEXT_CLAUDE_CONTEXT_ASSEMBLY"), + bootstrap_context=_environment_bool( + "POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT", + default=False, + ), + bootstrap_max_bytes=_environment_int( + "POWERCONTEXT_CLAUDE_BOOTSTRAP_MAX_BYTES", + default=4096, + ), + bootstrap_handoff=_environment_bootstrap_handoff("POWERCONTEXT_CLAUDE_BOOTSTRAP_HANDOFF"), include_code=_environment_bool("POWERCONTEXT_CLAUDE_INCLUDE_CODE", default=False), capture_prompts=_environment_bool( "POWERCONTEXT_CLAUDE_CAPTURE_PROMPTS", @@ -158,6 +198,20 @@ def _environment_object(name: str) -> dict[str, object] | None: return parsed +def _environment_bootstrap_handoff(name: str) -> BootstrapHandoff | None: + value = _environment_object(name) + if value is None: + return None + if not {"artifact_id", "revision"} <= set(value) or not set(value) <= {"family", "artifact_id", "revision"}: + raise ValueError("PowerContext bootstrap Handoff must be an exact identity") # noqa: TRY003 + artifact_id = value.get("artifact_id") + revision = value.get("revision") + family = value.get("family", "handoff") + if not isinstance(artifact_id, str) or not isinstance(revision, int) or not isinstance(family, str): + raise TypeError("PowerContext bootstrap Handoff must be an exact identity") # noqa: TRY003 + return BootstrapHandoff(artifact_id=artifact_id, revision=revision, family=family) + + def _environment_bool(*names: str, default: bool) -> bool: value = _first_environment(*names) if value is None: @@ -248,4 +302,4 @@ def _http_base_url(value: str, *, allow_insecure_http: bool = False) -> str: return urlunsplit((scheme, netloc, path, "", "")).rstrip("/") -__all__ = ["ClaudeCodePluginSettings"] +__all__ = ["BootstrapHandoff", "ClaudeCodePluginSettings"] diff --git a/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py b/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py new file mode 100644 index 0000000000..8d48b3e895 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/hooks/bootstrap_context.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Strict dependency-free validation of the bootstrap HTTP contract.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from hashlib import sha256 +from typing import cast + +SCHEMA = "powercontext.bootstrap-context.v1" +PROFILE = "powercontext.scope-bootstrap.v1" +_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +_RESULT_KEYS = { + "schema", + "status", + "reason", + "profile", + "content", + "content_bytes", + "package_digest", + "items", + "truncated", + "receipt", +} +_ITEM_KEYS = { + "kind", + "scope_id", + "artifact", + "content_digest", + "truncated", +} + + +class InvalidBootstrapResponse(ValueError): + pass + + +def validate_bootstrap_context(value: Mapping[str, object], *, max_bytes: int) -> dict[str, object]: + if set(value) != _RESULT_KEYS or value.get("schema") != SCHEMA or value.get("profile") != PROFILE: + raise InvalidBootstrapResponse + status = value.get("status") + reason = value.get("reason") + content = value.get("content") + content_bytes = value.get("content_bytes") + package_digest = value.get("package_digest") + items = value.get("items") + truncated = value.get("truncated") + receipt = value.get("receipt") + if ( + status not in {"ready", "empty", "skipped"} + or ( + reason is not None + and reason not in {"disabled", "no_eligible_context", "already_delivered", "preparation_failed"} + ) + or not isinstance(content_bytes, int) + or isinstance(content_bytes, bool) + or not isinstance(items, list) + or len(items) > 7 + or not isinstance(truncated, bool) + ): + raise InvalidBootstrapResponse + validated_receipt = validate_delivery_receipt(receipt) + validated_items = [_validate_item(item) for item in items] + if status == "ready": + if ( + reason is not None + or not isinstance(content, str) + or not content.strip() + or content_bytes != len(content.encode("utf-8")) + or content_bytes > max_bytes + or not isinstance(package_digest, str) + or _DIGEST.fullmatch(package_digest) is None + or package_digest != "sha256:" + sha256(content.encode("utf-8")).hexdigest() + or not validated_items + or validated_receipt["state"] != "pending" + ): + raise InvalidBootstrapResponse + elif ( + content is not None + or content_bytes != 0 + or package_digest is not None + or validated_items + or reason is None + or validated_receipt["state"] == "pending" + ): + raise InvalidBootstrapResponse + if status == "empty" and (reason != "no_eligible_context" or validated_receipt["state"] != "skipped"): + raise InvalidBootstrapResponse + if status == "skipped" and ( + reason == "no_eligible_context" + or (reason == "disabled" and validated_receipt["state"] != "skipped") + or (reason == "preparation_failed" and validated_receipt["state"] != "failed") + ): + raise InvalidBootstrapResponse + return dict(value) + + +def validate_delivery_receipt(value: object, *, receipt_id: str | None = None) -> dict[str, str]: + if not isinstance(value, dict) or set(value) != {"receipt_id", "state"}: + raise InvalidBootstrapResponse + identifier, state = value.get("receipt_id"), value.get("state") + if ( + not _printable_identifier(identifier, maximum=64) + or state not in {"pending", "injected", "skipped", "failed"} + or (receipt_id is not None and identifier != receipt_id) + ): + raise InvalidBootstrapResponse + return {"receipt_id": cast(str, identifier), "state": cast(str, state)} + + +def _validate_item(value: object) -> dict[str, object]: + if not isinstance(value, dict) or set(value) != _ITEM_KEYS: + raise InvalidBootstrapResponse + kind = value.get("kind") + scope_id = value.get("scope_id") + artifact = value.get("artifact") + digest = value.get("content_digest") + if ( + kind not in {"atomic-memory", "handoff"} + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(artifact, dict) + or set(artifact) != {"family", "artifact_id", "revision"} + or not _printable_identifier(artifact.get("artifact_id"), maximum=128) + or not isinstance(artifact.get("revision"), int) + or isinstance(artifact.get("revision"), bool) + or cast(int, artifact.get("revision")) < 1 + or not isinstance(digest, str) + or _DIGEST.fullmatch(digest) is None + or not isinstance(value.get("truncated"), bool) + ): + raise InvalidBootstrapResponse + if artifact.get("family") != kind: + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _printable_identifier(value: object, *, maximum: int) -> bool: + return ( + isinstance(value, str) + and 1 <= len(value) <= maximum + and all("\x21" <= character <= "\x7e" for character in value) + ) diff --git a/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py b/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py index 7f3e8c3d81..11ce9151f4 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py +++ b/integrations/claude-code/plugins/powercontext/hooks/diagnostics.py @@ -23,7 +23,14 @@ from contextlib import contextmanager from pathlib import Path -_FAILURE_OUTCOMES = frozenset({"authentication_failed", "version_mismatch", "server_unavailable", "invalid_response"}) +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) _COOLDOWN_SECONDS = 60.0 diff --git a/integrations/claude-code/plugins/powercontext/hooks/hooks.json b/integrations/claude-code/plugins/powercontext/hooks/hooks.json index d1d2ee05d4..cf6ecc2c33 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/hooks.json +++ b/integrations/claude-code/plugins/powercontext/hooks/hooks.json @@ -1,6 +1,22 @@ { - "description": "Recall relevant memory and capture the current Claude Code prompt as a Source.", + "description": "Bootstrap scoped context, recall relevant memory, and capture the current Claude Code prompt as a Source.", "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact|fork", + "hooks": [ + { + "type": "command", + "command": "python3", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py" + ], + "timeout": 10, + "statusMessage": "Loading PowerContext context" + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/integrations/claude-code/plugins/powercontext/hooks/session_start.py b/integrations/claude-code/plugins/powercontext/hooks/session_start.py new file mode 100644 index 0000000000..1df3a08984 --- /dev/null +++ b/integrations/claude-code/plugins/powercontext/hooks/session_start.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Inject one bounded PowerContext package at Claude Code SessionStart.""" + +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Mapping +from contextlib import suppress +from pathlib import Path +from time import monotonic +from typing import Any, Protocol, cast +from urllib.error import HTTPError +from urllib.request import Request + +_PLUGIN_ROOT = Path(__file__).resolve().parents[1] +_SCRIPTS_ROOT = _PLUGIN_ROOT / "scripts" +sys.path.insert(0, str(_PLUGIN_ROOT)) +sys.path.insert(0, str(_SCRIPTS_ROOT)) + +from bootstrap_state import ( # noqa: E402 + clear_receipt_if_matches, + load_receipt_state, + save_receipt, +) +from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 +from hooks.bootstrap_context import ( # noqa: E402 + InvalidBootstrapResponse, + validate_bootstrap_context, + validate_delivery_receipt, +) +from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 +from scope_binding_errors import ( # noqa: E402 + ScopeBindingError, + ScopeBindingRejectedError, + ScopeBindingStatusError, + ScopeBindingUnavailableError, +) +from workspace_scope import ( # noqa: E402 + bind_response_deadline, + open_bounded, + resolve_scope_id, +) + +_LIFECYCLES = frozenset({"startup", "resume", "clear", "compact", "fork"}) +_MAX_RESPONSE_BYTES = 1_048_576 +_READ_CHUNK_BYTES = 65_536 +_BOOTSTRAP_PROFILE = "powercontext.scope-bootstrap.v1" +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) +_REQUEST_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "powercontext-claude-code-plugin/0.1.3", +} + + +class _ReadableResponse(Protocol): + status: int + + def read(self, amount: int = -1) -> bytes: ... + + def __enter__(self) -> _ReadableResponse: ... + + def __exit__(self, *args: object) -> object: ... + + +class _HttpStatusError(RuntimeError): + def __init__(self, status: int, path: str) -> None: + self.status = status + self.path = path + super().__init__(f"PowerContext returned HTTP {status}") + + +def main(settings: ClaudeCodePluginSettings | None = None) -> int: + """Process one SessionStart payload without ever blocking Claude Code.""" + + try: + payload = cast(dict[str, Any], json.load(sys.stdin)) + session_id = payload.get("session_id") + cwd = payload.get("cwd") + source = payload.get("source") + if ( + not isinstance(session_id, str) + or not isinstance(cwd, str) + or not isinstance(source, str) + or source not in _LIFECYCLES + ): + return 0 + previous_receipt = load_receipt_state(session_id) + try: + settings = ClaudeCodePluginSettings.from_environment() if settings is None else settings + except Exception: + _clear_observed_receipt(session_id, previous_receipt) + return 0 + deadline = monotonic() + settings.http_budget_seconds + prepared = _resolve_and_prepare(cwd, session_id, source, payload, settings=settings, deadline=deadline) + if prepared is None: + _clear_observed_receipt(session_id, previous_receipt) + return 0 + scope_id, response = prepared + if response["status"] != "ready": + receipt = _synchronize_non_ready_receipt(session_id, scope_id, response, previous_receipt) + if response["status"] == "empty": + _emit_bootstrap_event( + "empty", + http_status=200, + context_status="empty", + content_bytes=0, + truncated=cast(bool, response["truncated"]), + receipt_state=receipt["state"], + ) + return 0 + receipt = cast(dict[str, str], response["receipt"]) + receipt_id = receipt["receipt_id"] + try: + delivered = validate_delivery_receipt( + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "injected"}, + settings=settings, + deadline=deadline, + ), + receipt_id=receipt_id, + ) + _require_injected(delivered) + except Exception: + _clear_stale_receipt_after_failed_claim(session_id, scope_id, receipt_id, previous_receipt) + _emit_bootstrap_event("delivery_failed") + with suppress(Exception): + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "failed"}, + settings=settings, + deadline=deadline, + ) + return 0 + try: + save_receipt(session_id, scope_id, receipt_id) + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": response["content"], + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + sys.stdout.flush() + except Exception as error: + _handle_output_failure(session_id, scope_id, receipt_id, error) + return 0 + except Exception: + return 0 + return 0 + + +def _resolve_and_prepare( + cwd: str, + session_id: str, + source: str, + payload: Mapping[str, object], + *, + settings: ClaudeCodePluginSettings, + deadline: float, +) -> tuple[str, dict[str, object]] | None: + try: + scope_id = resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=deadline, + ) + request: dict[str, object] = { + "scope_id": scope_id, + "enabled": settings.bootstrap_context, + "profile": _BOOTSTRAP_PROFILE, + "lifecycle": source, + "integration": "claude-code", + "max_bytes": settings.bootstrap_max_bytes, + } + if settings.bootstrap_handoff is not None: + request["handoff"] = settings.bootstrap_handoff.as_request() + event_id = _identifier(payload, "event_id", "hook_id", "invocation_id", "request_id") + if event_id is not None: + request["event_id"] = event_id + response = validate_bootstrap_context( + _post_json("/v1/context/bootstrap", request, settings=settings, deadline=deadline), + max_bytes=settings.bootstrap_max_bytes, + ) + except ScopeBindingRejectedError: + _emit_bootstrap_event("authentication_failed") + except (ScopeBindingUnavailableError, OSError, TimeoutError): + _emit_bootstrap_event("server_unavailable", recovery="powercontext doctor") + except (ScopeBindingStatusError, _HttpStatusError) as error: + _emit_http_failure(error.status) + except (ScopeBindingError, InvalidBootstrapResponse, UnicodeDecodeError, json.JSONDecodeError): + _emit_bootstrap_event("invalid_response") + else: + return scope_id, response + return None + + +def _identifier(payload: Mapping[str, object], *names: str) -> str | None: + for name in names: + value = payload.get(name) + if isinstance(value, str) and value.strip() and len(value.strip()) <= 512: + return value.strip() + return None + + +def _post_json( + path: str, + payload: Mapping[str, object], + *, + settings: ClaudeCodePluginSettings, + deadline: float, +) -> dict[str, object]: + headers = dict(_REQUEST_HEADERS) + if settings.authorization is not None: + headers["Authorization"] = settings.authorization + request = Request( # noqa: S310 - settings validates the configured transport. + f"{settings.server_url}{path}", + data=json.dumps(payload, separators=(",", ":")).encode(), + headers=headers, + method="POST", + ) + timeout = min(settings.request_timeout_seconds, _remaining_time(deadline)) + try: + with open_bounded(request, timeout=timeout) as response: + if response.status != 200: + raise _HttpStatusError(response.status, path) + value = json.loads(_read_response(response, deadline=min(deadline, monotonic() + timeout))) + except HTTPError as error: + raise _HttpStatusError(error.code, path) from error + if not isinstance(value, dict): + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _read_response(response: _ReadableResponse, *, deadline: float) -> bytes: + bind_response_deadline(response, deadline) + content = bytearray() + while True: + remaining = _MAX_RESPONSE_BYTES + 1 - len(content) + chunk = response.read(min(_READ_CHUNK_BYTES, remaining)) + if not chunk: + return bytes(content) + content.extend(chunk) + if len(content) > _MAX_RESPONSE_BYTES: + raise InvalidBootstrapResponse + + +def _remaining_time(deadline: float) -> float: + remaining = deadline - monotonic() + if remaining <= 0: + raise TimeoutError + return remaining + + +def _require_injected(receipt: Mapping[str, str]) -> None: + if receipt.get("state") != "injected": + raise InvalidBootstrapResponse + + +def _clear_stale_receipt_after_failed_claim( + session_id: str, + scope_id: str, + receipt_id: str, + previous_receipt: tuple[str, str] | None, +) -> None: + if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): + clear_receipt_if_matches(session_id, *previous_receipt) + + +def _clear_observed_receipt(session_id: str, previous_receipt: tuple[str, str] | None) -> None: + if previous_receipt is not None: + clear_receipt_if_matches(session_id, *previous_receipt) + + +def _handle_output_failure(session_id: str, scope_id: str, receipt_id: str, error: Exception) -> None: + clear_receipt_if_matches(session_id, scope_id, receipt_id) + if isinstance(error, BrokenPipeError): + try: + stdout = sys.stdout.fileno() + descriptor = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(descriptor, stdout) + finally: + os.close(descriptor) + except (AttributeError, OSError, ValueError): + pass + _emit_bootstrap_event("delivery_failed") + + +def _synchronize_non_ready_receipt( + session_id: str, + scope_id: str, + response: Mapping[str, object], + previous_receipt: tuple[str, str] | None, +) -> dict[str, str]: + """Retain a receipt only when this exact lifecycle event was already injected.""" + + receipt = cast(dict[str, str], response["receipt"]) + if ( + response.get("status") == "skipped" + and response.get("reason") == "already_delivered" + and receipt["state"] == "injected" + and previous_receipt == (scope_id, receipt["receipt_id"]) + ): + return receipt + _clear_observed_receipt(session_id, previous_receipt) + return receipt + + +def _emit_http_failure(status: int) -> None: + if status == 401: + outcome = "authentication_failed" + elif status == 403: + outcome = "authorization_denied" + elif status == 404: + outcome = "version_mismatch" + elif status == 503: + outcome = "server_unavailable" + else: + outcome = "invalid_response" + _emit_bootstrap_event( + outcome, + http_status=status, + recovery="powercontext doctor" if outcome == "server_unavailable" else None, + ) + + +def _emit_bootstrap_event( + outcome: str, + *, + http_status: int | None = None, + context_status: str | None = None, + content_bytes: int | None = None, + truncated: bool | None = None, + receipt_state: str | None = None, + recovery: str | None = None, +) -> None: + if outcome in _FAILURE_OUTCOMES and not _should_emit_diagnostic(outcome): + return + event: dict[str, object] = { + "component": "powercontext.claude_code.bootstrap", + "event": "context_bootstrap", + "outcome": outcome, + "profile": _BOOTSTRAP_PROFILE, + } + if http_status is not None: + event["http_status"] = http_status + if context_status is not None: + event["context_status"] = context_status + if content_bytes is not None: + event["content_bytes"] = content_bytes + if truncated is not None: + event["truncated"] = truncated + if receipt_state is not None: + event["receipt_state"] = receipt_state + if recovery is not None: + event["recovery"] = recovery + sys.stderr.write(json.dumps(event, separators=(",", ":")) + "\n") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py index dfe544a795..0380c4e685 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py +++ b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py @@ -33,6 +33,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) +from bootstrap_state import clear_receipt, load_receipt # noqa: E402 from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 from hooks import prepared_context as _prepared_context # noqa: E402 from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 @@ -47,7 +48,7 @@ _REQUEST_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "powercontext-claude-code-plugin/0.1.2", + "User-Agent": "powercontext-claude-code-plugin/0.1.3", } _FAILURE_OUTCOMES = frozenset({"authentication_failed", "version_mismatch", "server_unavailable", "invalid_response"}) @@ -137,12 +138,15 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: return 0 http_deadline = monotonic() + settings.http_budget_seconds + session_id = _payload_identifier(payload, "session_id") scope_id = resolve_scope_id( cwd, - session_id=_payload_identifier(payload, "session_id"), + session_id=session_id, settings=settings, deadline=http_deadline, ) + bootstrap_receipt_id = load_receipt(session_id, scope_id) + clear_receipt(session_id) context = None with suppress(Exception): context = _recall_context( @@ -150,6 +154,7 @@ def main(settings: ClaudeCodePluginSettings | None = None) -> int: scope_id, settings=settings, deadline=http_deadline, + bootstrap_receipt_id=bootstrap_receipt_id, emitted_diagnostics=emitted_diagnostics, diagnostic_events=diagnostic_events, ) @@ -209,6 +214,7 @@ def _prepare_context( *, settings: ClaudeCodePluginSettings, deadline: float, + bootstrap_receipt_id: str | None = None, ) -> Mapping[str, object]: return _post_json( "/v1/context/prepare", @@ -218,6 +224,7 @@ def _prepare_context( "max_bytes": _MAX_CONTEXT_BYTES, **({"include_code": True} if settings.include_code else {}), **({"assembly": settings.context_assembly} if settings.context_assembly is not None else {}), + **({"bootstrap_receipt_id": bootstrap_receipt_id} if bootstrap_receipt_id is not None else {}), }, settings=settings, deadline=deadline, @@ -392,11 +399,22 @@ def _recall_context( *, settings: ClaudeCodePluginSettings, deadline: float, + bootstrap_receipt_id: str | None = None, emitted_diagnostics: set[str] | None = None, diagnostic_events: list[dict[str, object]] | None = None, ) -> str | None: try: - prepared = _validate_prepared_context(_prepare_context(query, scope_id, settings=settings, deadline=deadline)) + if bootstrap_receipt_id is None: + response = _prepare_context(query, scope_id, settings=settings, deadline=deadline) + else: + response = _prepare_context( + query, + scope_id, + settings=settings, + deadline=deadline, + bootstrap_receipt_id=bootstrap_receipt_id, + ) + prepared = _validate_prepared_context(response) except _HttpStatusError as error: outcome = _http_failure_outcome(error, operation="context_prepare") if outcome is not None: diff --git a/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py b/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py index d84f06b730..f50619b8ab 100644 --- a/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py +++ b/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py @@ -56,7 +56,7 @@ def override(method: _MethodT, /) -> _MethodT: _REQUEST_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "powercontext-claude-code-plugin/0.1.2", + "User-Agent": "powercontext-claude-code-plugin/0.1.3", } diff --git a/integrations/codex/plugins/powercontext/README.md b/integrations/codex/plugins/powercontext/README.md index fa39ebe420..e7040a5d64 100644 --- a/integrations/codex/plugins/powercontext/README.md +++ b/integrations/codex/plugins/powercontext/README.md @@ -5,6 +5,8 @@ does not embed storage or start the server. The integration uses each public surface for the job it fits: +- the opt-in `SessionStart` hook injects one bounded, curated bootstrap package + before the first prompt and records a content-free delivery receipt; - the `UserPromptSubmit` hook first calls `POST /v1/context/prepare`, then independently captures the current prompt with `POST /v1/sources/content`; - Streamable HTTP MCP at `http://127.0.0.1:8000/mcp` gives Codex the curated @@ -79,6 +81,16 @@ default Scope. `UserPromptSubmit` uses that binding for recall and capture. Agent-supplied `scope_id` cannot redirect a write. Repository and directory identities are binding lookup inputs only; they never generate a Scope ID. +Bootstrap context is disabled by default. Set +`POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT=true` to include active Atomic Memory +Artifacts explicitly tagged `bootstrap-context`; use +`POWERCONTEXT_CODEX_BOOTSTRAP_MAX_BYTES` for the 512–8192-byte budget and +`POWERCONTEXT_CODEX_BOOTSTRAP_HANDOFF` for one exact committed Handoff identity. +The first ordinary recall carries the successful receipt so fully delivered +exact Atomic Memory revisions are not repeated; truncated Artifacts remain eligible. +Invalid configuration or another failure before a new package is established +clears any stale saved receipt and injects nothing. + Set `POWERCONTEXT_CODEX_SCOPE_ID` only when the host must explicitly bind every request to one known Scope. `.mcp.json` is the single Server endpoint configuration consumed by Codex and diff --git a/integrations/codex/plugins/powercontext/bootstrap_state.py b/integrations/codex/plugins/powercontext/bootstrap_state.py new file mode 100644 index 0000000000..eff55ff347 --- /dev/null +++ b/integrations/codex/plugins/powercontext/bootstrap_state.py @@ -0,0 +1,171 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Persist only a hashed Session key and the latest bootstrap receipt identity.""" + +from __future__ import annotations + +import json +import os +import stat +from collections.abc import Iterator +from contextlib import contextmanager, suppress +from hashlib import sha256 +from pathlib import Path +from uuid import uuid4 + + +def load_receipt(session_id: str | None, scope_id: str) -> str | None: + state = load_receipt_state(session_id) + return state[1] if state is not None and state[0] == scope_id else None + + +def load_receipt_state(session_id: str | None) -> tuple[str, str] | None: + path = _state_path(session_id) + if path is None: + return None + return _read_receipt_state(path) + + +def _read_receipt_state(path: Path) -> tuple[str, str] | None: + try: + if path.is_symlink() or not path.is_file() or (os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077): + return None + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or set(value) != {"version", "scope_id", "receipt_id"}: + return None + scope_id = value.get("scope_id") + receipt_id = value.get("receipt_id") + if ( + value.get("version") != 1 + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(receipt_id, str) + or not 1 <= len(receipt_id) <= 64 + or not all("\x21" <= character <= "\x7e" for character in receipt_id) + ): + return None + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + return scope_id, receipt_id + + +def save_receipt(session_id: str | None, scope_id: str, receipt_id: str) -> None: + path = _state_path(session_id) + if path is None: + raise OSError + root = path.parent + root.mkdir(mode=0o700, parents=True, exist_ok=True) + if root.is_symlink() or not root.is_dir(): + raise OSError + if os.name != "nt": + root.chmod(0o700) + payload = json.dumps( + {"version": 1, "scope_id": scope_id, "receipt_id": receipt_id}, + separators=(",", ":"), + ).encode() + with _locked(path): + temporary = root / f".{path.name}.{uuid4().hex}.tmp" + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(temporary, flags, 0o600) + try: + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(0o600) + finally: + os.close(descriptor) + with suppress(FileNotFoundError): + temporary.unlink() + + +def clear_receipt(session_id: str | None) -> None: + path = _state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if not path.is_symlink(): + path.unlink(missing_ok=True) + except OSError: + return + + +def clear_receipt_if_matches( + session_id: str | None, + scope_id: str, + receipt_id: str, +) -> None: + path = _state_path(session_id) + if path is None or not path.parent.exists(): + return + try: + with _locked(path): + if _read_receipt_state(path) == (scope_id, receipt_id): + path.unlink(missing_ok=True) + except OSError: + return + + +@contextmanager +def _locked(path: Path) -> Iterator[None]: + lock_path = path.with_name(f".{path.name}.lock") + with lock_path.open("a+b") as lock_file: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +def _state_path(session_id: str | None) -> Path | None: + if session_id is None or not session_id.strip(): + return None + configured = os.environ.get("POWERCONTEXT_BOOTSTRAP_STATE_DIR") + plugin_data = os.environ.get("PLUGIN_DATA") + if configured is None and plugin_data is None: + return None + root = Path(configured or plugin_data or "").expanduser() + if not root.is_absolute(): + return None + digest = sha256(session_id.strip().encode()).hexdigest() + return root / "powercontext-bootstrap" / f"{digest}.json" diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index f1f7b2f317..91dd7ff22a 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -75,6 +75,7 @@ "list_remote_skill_targets", "merge_atomic_memories", "prepare_context", + "prepare_bootstrap_context", "prepare_handoff", "prepare_handoff_hint", "preview_atomic_memory_restoration", @@ -84,6 +85,7 @@ "publish_remote_skill", "query_code", "record_skill_usage", + "record_bootstrap_delivery", "record_task_outcome", "reject_artifact_candidate", "remember_memory", diff --git a/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py b/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py new file mode 100644 index 0000000000..0764596d15 --- /dev/null +++ b/integrations/codex/plugins/powercontext/hooks/bootstrap_context.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Strict dependency-free validation of the bootstrap HTTP contract.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from hashlib import sha256 +from typing import cast + +SCHEMA = "powercontext.bootstrap-context.v1" +PROFILE = "powercontext.scope-bootstrap.v1" +_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +_RESULT_KEYS = { + "schema", + "status", + "reason", + "profile", + "content", + "content_bytes", + "package_digest", + "items", + "truncated", + "receipt", +} +_ITEM_KEYS = { + "kind", + "scope_id", + "artifact", + "content_digest", + "truncated", +} + + +class InvalidBootstrapResponse(ValueError): + pass + + +def validate_bootstrap_context(value: Mapping[str, object], *, max_bytes: int) -> dict[str, object]: + if set(value) != _RESULT_KEYS or value.get("schema") != SCHEMA or value.get("profile") != PROFILE: + raise InvalidBootstrapResponse + status = value.get("status") + reason = value.get("reason") + content = value.get("content") + content_bytes = value.get("content_bytes") + package_digest = value.get("package_digest") + items = value.get("items") + truncated = value.get("truncated") + receipt = value.get("receipt") + if ( + status not in {"ready", "empty", "skipped"} + or ( + reason is not None + and reason not in {"disabled", "no_eligible_context", "already_delivered", "preparation_failed"} + ) + or not isinstance(content_bytes, int) + or isinstance(content_bytes, bool) + or not isinstance(items, list) + or len(items) > 7 + or not isinstance(truncated, bool) + ): + raise InvalidBootstrapResponse + validated_receipt = validate_delivery_receipt(receipt) + validated_items = [_validate_item(item) for item in items] + if status == "ready": + if ( + reason is not None + or not isinstance(content, str) + or not content.strip() + or content_bytes != len(content.encode("utf-8")) + or content_bytes > max_bytes + or not isinstance(package_digest, str) + or _DIGEST.fullmatch(package_digest) is None + or package_digest != "sha256:" + sha256(content.encode("utf-8")).hexdigest() + or not validated_items + or validated_receipt["state"] != "pending" + ): + raise InvalidBootstrapResponse + elif ( + content is not None + or content_bytes != 0 + or package_digest is not None + or validated_items + or reason is None + or validated_receipt["state"] == "pending" + ): + raise InvalidBootstrapResponse + if status == "empty" and (reason != "no_eligible_context" or validated_receipt["state"] != "skipped"): + raise InvalidBootstrapResponse + if status == "skipped" and ( + reason == "no_eligible_context" + or (reason == "disabled" and validated_receipt["state"] != "skipped") + or (reason == "preparation_failed" and validated_receipt["state"] != "failed") + ): + raise InvalidBootstrapResponse + return dict(value) + + +def validate_delivery_receipt(value: object, *, receipt_id: str | None = None) -> dict[str, str]: + if not isinstance(value, dict) or set(value) != {"receipt_id", "state"}: + raise InvalidBootstrapResponse + identifier, state = value.get("receipt_id"), value.get("state") + if ( + not _printable_identifier(identifier, maximum=64) + or state not in {"pending", "injected", "skipped", "failed"} + or (receipt_id is not None and identifier != receipt_id) + ): + raise InvalidBootstrapResponse + return {"receipt_id": cast(str, identifier), "state": cast(str, state)} + + +def _validate_item(value: object) -> dict[str, object]: + if not isinstance(value, dict) or set(value) != _ITEM_KEYS: + raise InvalidBootstrapResponse + kind = value.get("kind") + scope_id = value.get("scope_id") + artifact = value.get("artifact") + digest = value.get("content_digest") + if ( + kind not in {"atomic-memory", "handoff"} + or not isinstance(scope_id, str) + or not scope_id.strip() + or len(scope_id) > 256 + or not isinstance(artifact, dict) + or set(artifact) != {"family", "artifact_id", "revision"} + or not _printable_identifier(artifact.get("artifact_id"), maximum=128) + or not isinstance(artifact.get("revision"), int) + or isinstance(artifact.get("revision"), bool) + or cast(int, artifact.get("revision")) < 1 + or not isinstance(digest, str) + or _DIGEST.fullmatch(digest) is None + or not isinstance(value.get("truncated"), bool) + ): + raise InvalidBootstrapResponse + if artifact.get("family") != kind: + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _printable_identifier(value: object, *, maximum: int) -> bool: + return ( + isinstance(value, str) + and 1 <= len(value) <= maximum + and all("\x21" <= character <= "\x7e" for character in value) + ) diff --git a/integrations/codex/plugins/powercontext/hooks/diagnostics.py b/integrations/codex/plugins/powercontext/hooks/diagnostics.py index 782090f2ff..ad36fc6a2f 100644 --- a/integrations/codex/plugins/powercontext/hooks/diagnostics.py +++ b/integrations/codex/plugins/powercontext/hooks/diagnostics.py @@ -23,7 +23,14 @@ from contextlib import contextmanager from pathlib import Path -_FAILURE_OUTCOMES = frozenset({"authentication_failed", "version_mismatch", "server_unavailable", "invalid_response"}) +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) _COOLDOWN_SECONDS = 60.0 diff --git a/integrations/codex/plugins/powercontext/hooks/hooks.json b/integrations/codex/plugins/powercontext/hooks/hooks.json index 634e4726eb..6634346977 100644 --- a/integrations/codex/plugins/powercontext/hooks/hooks.json +++ b/integrations/codex/plugins/powercontext/hooks/hooks.json @@ -1,14 +1,16 @@ { - "description": "Recall relevant memory and capture the current Codex prompt as a Source.", + "description": "Bootstrap scoped context, recall relevant memory, and capture the current Codex prompt.", "hooks": { "SessionStart": [ { + "matcher": "startup|resume|clear|compact", "hooks": [ { "type": "command", "command": "uv run --frozen --quiet --project \"${PLUGIN_ROOT}\" python \"${PLUGIN_ROOT}/hooks/session_binding.py\"", "timeout": 10, - "statusMessage": "Binding PowerContext" + "statusMessage": "Loading PowerContext context", + "additionalContextLimit": 8192 } ] } diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index ddd12b2e1d..7a66fe3ba9 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -35,6 +35,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) +from bootstrap_state import clear_receipt, load_receipt # noqa: E402 from hooks import prepared_context as _prepared_context # noqa: E402 from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 from plugin_version import PLUGIN_VERSION # noqa: E402 @@ -146,11 +147,14 @@ def main(settings: CodexPluginSettings | None = None) -> int: settings=settings, deadline=http_deadline, ) + bootstrap_receipt_id = load_receipt(session_id, scope_id) + clear_receipt(session_id) context = _recall_context( prompt, scope_id, settings=settings, deadline=http_deadline, + bootstrap_receipt_id=bootstrap_receipt_id, emitted_diagnostics=emitted_diagnostics, diagnostic_events=diagnostic_events, ) @@ -228,6 +232,7 @@ def _prepare_context( *, settings: CodexPluginSettings, deadline: float, + bootstrap_receipt_id: str | None = None, ) -> Mapping[str, object]: return _post_json( "/v1/context/prepare", @@ -237,6 +242,7 @@ def _prepare_context( "max_bytes": _MAX_CONTEXT_BYTES, **({"include_code": True} if settings.include_code else {}), **({"assembly": settings.context_assembly} if settings.context_assembly is not None else {}), + **({"bootstrap_receipt_id": bootstrap_receipt_id} if bootstrap_receipt_id is not None else {}), }, settings=settings, deadline=deadline, @@ -411,11 +417,23 @@ def _recall_context( *, settings: CodexPluginSettings, deadline: float, + bootstrap_receipt_id: str | None = None, emitted_diagnostics: set[str] | None = None, diagnostic_events: list[dict[str, object]] | None = None, ) -> str | None: try: - prepared = _validate_prepared_context(_prepare_context(query, scope_id, settings=settings, deadline=deadline)) + response = ( + _prepare_context(query, scope_id, settings=settings, deadline=deadline) + if bootstrap_receipt_id is None + else _prepare_context( + query, + scope_id, + settings=settings, + deadline=deadline, + bootstrap_receipt_id=bootstrap_receipt_id, + ) + ) + prepared = _validate_prepared_context(response) except _HttpStatusError as error: outcome = _http_failure_outcome(error, operation="context_prepare") if outcome is not None: diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py index 3fd371d2cf..110f1f60bf 100644 --- a/integrations/codex/plugins/powercontext/hooks/session_binding.py +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -13,24 +13,84 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Fix one Codex Session binding without blocking Session startup.""" +"""Bind a Codex Session and optionally inject one bounded bootstrap package.""" from __future__ import annotations import json +import os import sys +from collections.abc import Mapping +from contextlib import suppress from pathlib import Path from time import monotonic -from typing import Any, cast +from typing import Any, Protocol, cast +from urllib.error import HTTPError +from urllib.request import Request _PLUGIN_ROOT = Path(__file__).resolve().parents[1] _SCRIPTS_ROOT = _PLUGIN_ROOT / "scripts" sys.path.insert(0, str(_PLUGIN_ROOT)) sys.path.insert(0, str(_SCRIPTS_ROOT)) -from scope_binding import ScopeBindingError, resolve_scope_id # noqa: E402 +from bootstrap_state import ( # noqa: E402 + clear_receipt_if_matches, + load_receipt_state, + save_receipt, +) +from hooks.bootstrap_context import ( # noqa: E402 + InvalidBootstrapResponse, + validate_bootstrap_context, + validate_delivery_receipt, +) +from hooks.diagnostics import should_emit as _should_emit_diagnostic # noqa: E402 +from plugin_version import PLUGIN_VERSION # noqa: E402 +from scope_binding import ( # noqa: E402 + ScopeBindingError, + ScopeBindingRejectedError, + ScopeBindingStatusError, + ScopeBindingUnavailableError, + bind_response_deadline, + open_bounded, + resolve_scope_id, +) from settings import CodexPluginSettings # noqa: E402 +_LIFECYCLES = frozenset({"startup", "resume", "clear", "compact", "restore", "fork"}) +_MAX_RESPONSE_BYTES = 1_048_576 +_READ_CHUNK_BYTES = 65_536 +_BOOTSTRAP_PROFILE = "powercontext.scope-bootstrap.v1" +_FAILURE_OUTCOMES = frozenset({ + "authentication_failed", + "authorization_denied", + "delivery_failed", + "invalid_response", + "server_unavailable", + "version_mismatch", +}) +_REQUEST_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": f"powercontext-codex-plugin/{PLUGIN_VERSION}", +} + + +class _ReadableResponse(Protocol): + status: int + + def read(self, amount: int = -1) -> bytes: ... + + def __enter__(self) -> _ReadableResponse: ... + + def __exit__(self, *args: object) -> object: ... + + +class _HttpStatusError(RuntimeError): + def __init__(self, status: int, path: str) -> None: + self.status = status + self.path = path + super().__init__(f"PowerContext returned HTTP {status}") + def main(settings: CodexPluginSettings | None = None) -> int: try: @@ -39,17 +99,295 @@ def main(settings: CodexPluginSettings | None = None) -> int: cwd = payload.get("cwd") if not isinstance(session_id, str) or not isinstance(cwd, str): return 0 - settings = CodexPluginSettings() if settings is None else settings - resolve_scope_id( + source = payload.get("source") + if not isinstance(source, str) or source not in _LIFECYCLES: + settings = CodexPluginSettings() if settings is None else settings + resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=monotonic() + settings.http_budget_seconds, + persist_session=True, + ) + return 0 + previous_receipt = load_receipt_state(session_id) + try: + settings = CodexPluginSettings() if settings is None else settings + except Exception: + _clear_observed_receipt(session_id, previous_receipt) + return 0 + deadline = monotonic() + settings.http_budget_seconds + prepared = _resolve_and_prepare(cwd, session_id, source, payload, settings=settings, deadline=deadline) + if prepared is None: + _clear_observed_receipt(session_id, previous_receipt) + return 0 + scope_id, response = prepared + if response["status"] != "ready": + receipt = _synchronize_non_ready_receipt(session_id, scope_id, response, previous_receipt) + if response["status"] == "empty": + _emit_bootstrap_event( + "empty", + http_status=200, + context_status="empty", + content_bytes=0, + truncated=cast(bool, response["truncated"]), + receipt_state=receipt["state"], + ) + return 0 + receipt = cast(dict[str, str], response["receipt"]) + receipt_id = receipt["receipt_id"] + try: + delivered = validate_delivery_receipt( + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "injected"}, + settings=settings, + deadline=deadline, + ), + receipt_id=receipt_id, + ) + _require_injected(delivered) + except Exception: + _clear_stale_receipt_after_failed_claim(session_id, scope_id, receipt_id, previous_receipt) + _emit_bootstrap_event("delivery_failed") + with suppress(Exception): + _post_json( + "/v1/context/bootstrap/receipts", + {"scope_id": scope_id, "receipt_id": receipt_id, "outcome": "failed"}, + settings=settings, + deadline=deadline, + ) + return 0 + try: + save_receipt(session_id, scope_id, receipt_id) + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": response["content"], + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + sys.stdout.flush() + except Exception as error: + _handle_output_failure(session_id, scope_id, receipt_id, error) + return 0 + except Exception: + return 0 + return 0 + + +def _resolve_and_prepare( + cwd: str, + session_id: str, + source: str, + payload: Mapping[str, object], + *, + settings: CodexPluginSettings, + deadline: float, +) -> tuple[str, dict[str, object]] | None: + try: + scope_id = resolve_scope_id( cwd, session_id=session_id, settings=settings, - deadline=monotonic() + settings.http_budget_seconds, + deadline=deadline, persist_session=True, ) - except (ScopeBindingError, ValueError, OSError, json.JSONDecodeError): - return 0 - return 0 + request: dict[str, object] = { + "scope_id": scope_id, + "enabled": settings.bootstrap_context, + "profile": _BOOTSTRAP_PROFILE, + "lifecycle": source, + "integration": "codex", + "max_bytes": settings.bootstrap_max_bytes, + } + if settings.bootstrap_handoff is not None: + request["handoff"] = settings.bootstrap_handoff.model_dump() + event_id = _identifier(payload, "event_id", "hook_id", "invocation_id", "request_id") + if event_id is not None: + request["event_id"] = event_id + response = validate_bootstrap_context( + _post_json("/v1/context/bootstrap", request, settings=settings, deadline=deadline), + max_bytes=settings.bootstrap_max_bytes, + ) + except ScopeBindingRejectedError: + _emit_bootstrap_event("authentication_failed") + except (ScopeBindingUnavailableError, OSError, TimeoutError): + _emit_bootstrap_event("server_unavailable", recovery="powercontext doctor") + except (ScopeBindingStatusError, _HttpStatusError) as error: + _emit_http_failure(error.status) + except (ScopeBindingError, InvalidBootstrapResponse, UnicodeDecodeError, json.JSONDecodeError): + _emit_bootstrap_event("invalid_response") + else: + return scope_id, response + return None + + +def _identifier(payload: Mapping[str, object], *names: str) -> str | None: + for name in names: + value = payload.get(name) + if isinstance(value, str) and value.strip() and len(value.strip()) <= 512: + return value.strip() + return None + + +def _post_json( + path: str, + payload: Mapping[str, object], + *, + settings: CodexPluginSettings, + deadline: float, +) -> dict[str, object]: + headers = dict(_REQUEST_HEADERS) + if settings.authorization is not None: + headers["Authorization"] = settings.authorization.get_secret_value() + request = Request( # noqa: S310 - settings validates the configured transport. + f"{settings.server_url}{path}", + data=json.dumps(payload, separators=(",", ":")).encode(), + headers=headers, + method="POST", + ) + timeout = min(settings.request_timeout_seconds, _remaining_time(deadline)) + try: + with open_bounded(request, timeout=timeout) as response: + if response.status != 200: + raise _HttpStatusError(response.status, path) + value = json.loads(_read_response(response, deadline=min(deadline, monotonic() + timeout))) + except HTTPError as error: + raise _HttpStatusError(error.code, path) from error + if not isinstance(value, dict): + raise InvalidBootstrapResponse + return cast(dict[str, object], value) + + +def _read_response(response: _ReadableResponse, *, deadline: float) -> bytes: + bind_response_deadline(response, deadline) + content = bytearray() + while True: + remaining = _MAX_RESPONSE_BYTES + 1 - len(content) + chunk = response.read(min(_READ_CHUNK_BYTES, remaining)) + if not chunk: + return bytes(content) + content.extend(chunk) + if len(content) > _MAX_RESPONSE_BYTES: + raise InvalidBootstrapResponse + + +def _remaining_time(deadline: float) -> float: + remaining = deadline - monotonic() + if remaining <= 0: + raise TimeoutError + return remaining + + +def _require_injected(receipt: Mapping[str, str]) -> None: + if receipt.get("state") != "injected": + raise InvalidBootstrapResponse + + +def _clear_stale_receipt_after_failed_claim( + session_id: str, + scope_id: str, + receipt_id: str, + previous_receipt: tuple[str, str] | None, +) -> None: + if previous_receipt is not None and previous_receipt != (scope_id, receipt_id): + clear_receipt_if_matches(session_id, *previous_receipt) + + +def _clear_observed_receipt(session_id: str, previous_receipt: tuple[str, str] | None) -> None: + if previous_receipt is not None: + clear_receipt_if_matches(session_id, *previous_receipt) + + +def _handle_output_failure(session_id: str, scope_id: str, receipt_id: str, error: Exception) -> None: + clear_receipt_if_matches(session_id, scope_id, receipt_id) + if isinstance(error, BrokenPipeError): + try: + stdout = sys.stdout.fileno() + descriptor = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(descriptor, stdout) + finally: + os.close(descriptor) + except (AttributeError, OSError, ValueError): + pass + _emit_bootstrap_event("delivery_failed") + + +def _synchronize_non_ready_receipt( + session_id: str, + scope_id: str, + response: Mapping[str, object], + previous_receipt: tuple[str, str] | None, +) -> dict[str, str]: + """Retain a receipt only when this exact lifecycle event was already injected.""" + + receipt = cast(dict[str, str], response["receipt"]) + if ( + response.get("status") == "skipped" + and response.get("reason") == "already_delivered" + and receipt["state"] == "injected" + and previous_receipt == (scope_id, receipt["receipt_id"]) + ): + return receipt + _clear_observed_receipt(session_id, previous_receipt) + return receipt + + +def _emit_http_failure(status: int) -> None: + if status == 401: + outcome = "authentication_failed" + elif status == 403: + outcome = "authorization_denied" + elif status == 404: + outcome = "version_mismatch" + elif status == 503: + outcome = "server_unavailable" + else: + outcome = "invalid_response" + _emit_bootstrap_event( + outcome, + http_status=status, + recovery="powercontext doctor" if outcome == "server_unavailable" else None, + ) + + +def _emit_bootstrap_event( + outcome: str, + *, + http_status: int | None = None, + context_status: str | None = None, + content_bytes: int | None = None, + truncated: bool | None = None, + receipt_state: str | None = None, + recovery: str | None = None, +) -> None: + if outcome in _FAILURE_OUTCOMES and not _should_emit_diagnostic(outcome): + return + event: dict[str, object] = { + "component": "powercontext.codex.bootstrap", + "event": "context_bootstrap", + "outcome": outcome, + "profile": _BOOTSTRAP_PROFILE, + } + if http_status is not None: + event["http_status"] = http_status + if context_status is not None: + event["context_status"] = context_status + if content_bytes is not None: + event["content_bytes"] = content_bytes + if truncated is not None: + event["truncated"] = truncated + if receipt_state is not None: + event["receipt_state"] = receipt_state + if recovery is not None: + event["recovery"] = recovery + sys.stderr.write(json.dumps(event, separators=(",", ":")) + "\n") if __name__ == "__main__": diff --git a/integrations/codex/plugins/powercontext/settings.py b/integrations/codex/plugins/powercontext/settings.py index 2828037e5f..8e8a611fad 100644 --- a/integrations/codex/plugins/powercontext/settings.py +++ b/integrations/codex/plugins/powercontext/settings.py @@ -78,6 +78,23 @@ def validate_server_set(self) -> _McpConfiguration: return self +class BootstrapHandoff(BaseModel): + """One explicitly selected exact committed Handoff.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + family: Literal["handoff"] = "handoff" + artifact_id: str = Field(min_length=1, max_length=128) + revision: int = Field(ge=1) + + @field_validator("artifact_id") + @classmethod + def validate_artifact_id(cls, value: str) -> str: + if value != value.strip() or not value.isascii() or not value.isprintable(): + raise ValueError("bootstrap Handoff identity must be printable ASCII") # noqa: TRY003 + return value + + class _McpEndpointSettingsSource(PydanticBaseSettingsSource): """Load the hook endpoint from the same file consumed by Codex MCP.""" @@ -122,6 +139,9 @@ class CodexPluginSettings(BaseSettings): authorization: SecretStr | None = Field(default=None, repr=False) scope_id: str | None = None context_assembly: dict[str, Any] | None = None + bootstrap_context: bool = False + bootstrap_max_bytes: int = Field(default=4096, ge=512, le=8192) + bootstrap_handoff: BootstrapHandoff | None = None include_code: bool = False capture_prompts: bool = True flush_on_capture: bool = False @@ -256,4 +276,4 @@ def _http_base_url(mcp_url: str, *, allow_insecure_http: bool = False) -> str: return urlunsplit((parsed.scheme, parsed.netloc, base_path, "", "")).rstrip("/") -__all__ = ["CodexPluginSettings"] +__all__ = ["BootstrapHandoff", "CodexPluginSettings"] diff --git a/integrations/dify/ACCEPTANCE.md b/integrations/dify/ACCEPTANCE.md index 9233002ca9..a225a10b07 100644 --- a/integrations/dify/ACCEPTANCE.md +++ b/integrations/dify/ACCEPTANCE.md @@ -22,7 +22,7 @@ The results and package checksum below describe the 19-tool baseline validated o ## Passed local acceptance -- 66 focused regressions cover registered SDK entries, official daemon serialization and host models/helpers: exact catalog, hidden credential/Scope fields, JSON-text inputs, input validation, UTF-8 limits, missing binding, credential read validation, malformed/oversized receipts, partial error receipts, transport failure, secret-bearing capture, sanitization and no automatic write retry. All six server-controlled fields are rejected at the SDK boundary. The packaged requirements check accepts the locked dependency export and rejects a stale SDK constraint without changing the lock. Named outputs are checked against their declared Draft 7 JSON schemas, and `result` must equal the complete successful response or `{}` on error/unknown. Ready and empty context responses retain their original text/null values; no-op candidate receipts retain `candidate=null`. Rendering metadata does not weaken nullable object or integer revision validation. +- 67 focused regressions cover registered SDK entries, official daemon serialization and host models/helpers: exact catalog, hidden credential/Scope fields, JSON-text inputs, input validation, UTF-8 limits, missing binding, credential read validation, malformed/oversized receipts, partial error receipts, transport failure, secret-bearing capture, sanitization and no automatic write retry. All seven server-controlled fields are rejected at the SDK boundary. The packaged requirements check accepts the locked dependency export and rejects a stale SDK constraint without changing the lock. Named outputs are checked against their declared Draft 7 JSON schemas, and `result` must equal the complete successful response or `{}` on error/unknown. Ready and empty context responses retain their original text/null values; no-op candidate receipts retain `candidate=null`. Rendering metadata does not weaken nullable object or integer revision validation. - The daemon probe imports the pinned official `plugin_entities.ToolDeclaration`/`ToolParameter` types and round-trips SDK registrations with Go `encoding/json`. Dify's real Pydantic parameter models and official model-schema builder then produce valid Draft 7 schemas for all 19 tools. All 26 decoded structured/nullable schemas remain in model-visible descriptions; defaults match the string transport. The compose fixture confirms that the tested daemon corresponds to the host's default version. This is executable source-boundary evidence, not installation or live daemon dispatch. - The HTTP/SQLite tests invoke all 19 loaded tools. Memory write/list/search/prepare/get/revise/retire remains usable through exact citations; Source capture retains structured metadata and a Unicode Source ID. - Complete Handoff JSON passes prepare/finalize/temporary continue/commit/exact/latest readback. Full objects and references survive the SDK messages. diff --git a/integrations/dify/plugin/README.md b/integrations/dify/plugin/README.md index 483941ae48..b465a88eeb 100644 --- a/integrations/dify/plugin/README.md +++ b/integrations/dify/plugin/README.md @@ -26,7 +26,7 @@ Configure exactly one of `scope_id` and `binding_external_id`. Every call resolv All users and conversations using one credential share its Scope. This is a fixed application/team boundary, not automatic personal or conversation isolation. Use distinct credential configurations and Server policies, or distinct deployments, when different access boundaries are needed. Paths, usernames and hashes are not Scope IDs. Provision bindings before use; the plugin never creates a Scope or falls back to Default. -The model cannot supply the top-level Scope, URL, token, binding, context assembly, or byte budget. Nested exact references and Handoff objects keep their original identities, and the Server validates their access relationships. +The model cannot supply the top-level Scope, URL, token, binding, context assembly, byte budget, or a host-owned bootstrap receipt. Nested exact references and Handoff objects keep their original identities, and the Server validates their access relationships. ## Tools diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 093ea09a80..823e2218c2 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -2879,6 +2879,13 @@ }, "assembly": { "$ref": "#/components/schemas/ContextAssembly" + }, + "bootstrap_receipt_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[\\x21-\\x7E]+$", + "description": "Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Atomic Memory revisions from an injected same-Scope receipt are removed before candidate limiting and sufficiency assessment; Artifacts whose bootstrap bodies were truncated remain eligible." } } }, diff --git a/integrations/dify/plugin/powercontext_dify/policy.py b/integrations/dify/plugin/powercontext_dify/policy.py index 0e19eb2c94..1cfec9087e 100644 --- a/integrations/dify/plugin/powercontext_dify/policy.py +++ b/integrations/dify/plugin/powercontext_dify/policy.py @@ -14,5 +14,15 @@ """Model-facing parameter policy shared by declarations and SDK validation.""" -HIDDEN_PARAMETERS = frozenset({"scope_id", "tag_filter", "expected_revision", "max_bytes", "include_code", "assembly"}) +HIDDEN_PARAMETERS = frozenset( + { + "scope_id", + "tag_filter", + "expected_revision", + "max_bytes", + "include_code", + "assembly", + "bootstrap_receipt_id", + } +) MEMORY_KINDS = ("decision", "constraint", "current-state", "task-outcome", "next-step", "agent-note") diff --git a/integrations/dify/scope-mapping.md b/integrations/dify/scope-mapping.md index dde609c237..8a76b7711c 100644 --- a/integrations/dify/scope-mapping.md +++ b/integrations/dify/scope-mapping.md @@ -12,7 +12,7 @@ An administrator provisions that key through PowerContext's supported Scope bind Credential validation uses the same resolution followed by protected `get_scope`. It does not write data or use an unauthenticated health endpoint as evidence of access. Save-time read validation cannot promise write permission; handle actual Server 403 responses. -The resolved Scope is injected into every selected operation. Top-level `scope_id`, URL, token, binding, expected Memory revision, context assembly and byte budget are not model parameters. Nested citations and prepared Handoff objects keep their original identities. The Server owns reference and cross-Scope access checks; a matching top-level Scope is not an authorization proof. +The resolved Scope is injected into every selected operation. Top-level `scope_id`, URL, token, binding, expected Memory revision, context assembly, byte budget and host-owned bootstrap receipt are not model parameters. Nested citations and prepared Handoff objects keep their original identities. The Server owns reference and cross-Scope access checks; a matching top-level Scope is not an authorization proof. Dify user/session identifiers are not used for automatic routing. One credential shares its Scope among its authorized applications/users. A shared administrator token represents one trust domain, even when Scope A/B searches are separate. Production resource isolation requires appropriately restricted Server credentials/policies or separate instances. Dynamic trusted identity routing needs a separate design and host/Server acceptance. diff --git a/integrations/dify/tests/test_plugin_contract.py b/integrations/dify/tests/test_plugin_contract.py index 1720621d14..c716d4c325 100644 --- a/integrations/dify/tests/test_plugin_contract.py +++ b/integrations/dify/tests/test_plugin_contract.py @@ -190,6 +190,7 @@ def test_sdk_registers_exact_catalog_and_json_text_inputs(registry): "max_bytes", "include_code", "assembly", + "bootstrap_receipt_id", "api_token", "server_url", } & {parameter.name for parameter in declaration.parameters} @@ -207,6 +208,7 @@ def test_sdk_registers_exact_catalog_and_json_text_inputs(registry): ("pc_prepare_context", {"query": "scope"}, "max_bytes", 1), ("pc_prepare_context", {"query": "scope"}, "include_code", True), ("pc_prepare_context", {"query": "scope"}, "assembly", {}), + ("pc_prepare_context", {"query": "scope"}, "bootstrap_receipt_id", "receipt-1"), ("pc_search", {"query": "scope"}, "tag_filter", {"tags": ["private"]}), ("pc_remember", {"kind": "decision", "text": "Retain."}, "expected_revision", 1), ], diff --git a/integrations/dify/tool-coverage.md b/integrations/dify/tool-coverage.md index 5266ede431..64b71fe3eb 100644 --- a/integrations/dify/tool-coverage.md +++ b/integrations/dify/tool-coverage.md @@ -29,7 +29,7 @@ The provider declares exactly the following 20 tools. `generate_contract.py --ch Memory tools retain their logical operation IDs. The adapter routes `pc_memory_list` to `list_atomic_memories`, Atomic reads to `get_artifact` or `get_artifact_revision`, revisions to `replace_artifact`, and forgetting to `change_atomic_memory_lifecycle`. -The administrator supplies Scope/binding, Server URL/token, context assembly and byte budget. The model supplies only operation-specific fields. Search limit is 8 rather than the HTTP default of 10 and maximum of 50. Memory kind is restricted to the six documented kinds; normalized Memory text is bounded by 8192 UTF-8 bytes. Search query keeps the HTTP character limit. Code indexing and tag filters are outside this provider surface. +The administrator supplies Scope/binding, Server URL/token, context assembly and byte budget. Host integrations, rather than the model, own bootstrap delivery receipts. The model supplies only operation-specific fields. Search limit is 8 rather than the HTTP default of 10 and maximum of 50. Memory kind is restricted to the six documented kinds; normalized Memory text is bounded by 8192 UTF-8 bytes. Search query keeps the HTTP character limit. Code indexing and tag filters are outside this provider surface. Objects, arrays and nullable inputs use strings containing one JSON value. The model-facing parameter description retains the decoded schema through the default daemon's discovery path; the plugin decodes once and validates the public HTTP contract. Nullable strings need JSON quotes, explicit null is the text `null`, and optional unused parameters may be omitted. Unknown fields, malformed/non-finite JSON, empty reference objects and malformed references fail before the operation request. Generation permits a combined 1–32 Source/Artifact references; empty array defaults are encoded as `[]`. Review's explicit family uses the text `"experience"`/`"skill"`; an omitted or JSON-null filter is unfiltered. See [Workflow serialization examples](plugin/README.md). diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 4808b1f571..17d49c5cb0 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -531,6 +531,28 @@ const OPERATIONS$1 = { successStatuses: [200], emptyStatuses: [] }, + prepare_bootstrap_context: { + method: "POST", + path: "/v1/context/bootstrap", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + record_bootstrap_delivery: { + method: "POST", + path: "/v1/context/bootstrap/receipts", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, create_work_contract: { method: "POST", path: "/v1/work/contracts/create", diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 34c0b7dd54..1e7d8c3a28 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -50,6 +50,8 @@ export const OPERATIONS = { commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, query_code: { method: 'POST', path: '/v1/scopes/{scope_id}/code/query', location: "body", scopeMode: 'current', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_bootstrap_context: { method: 'POST', path: '/v1/context/bootstrap', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_bootstrap_delivery: { method: 'POST', path: '/v1/context/bootstrap/receipts', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 34c0b7dd54..1e7d8c3a28 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -50,6 +50,8 @@ export const OPERATIONS = { commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, query_code: { method: 'POST', path: '/v1/scopes/{scope_id}/code/query', location: "body", scopeMode: 'current', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_bootstrap_context: { method: 'POST', path: '/v1/context/bootstrap', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_bootstrap_delivery: { method: 'POST', path: '/v1/context/bootstrap/receipts', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 34c0b7dd54..1e7d8c3a28 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -50,6 +50,8 @@ export const OPERATIONS = { commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, query_code: { method: 'POST', path: '/v1/scopes/{scope_id}/code/query', location: "body", scopeMode: 'current', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_bootstrap_context: { method: 'POST', path: '/v1/context/bootstrap', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_bootstrap_delivery: { method: 'POST', path: '/v1/context/bootstrap/receipts', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 92055b2780..57c3ff085a 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1242,6 +1242,79 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/context/bootstrap: + post: + tags: [context] + summary: Prepare bounded context for an Agent lifecycle boundary + description: >- + Prepare an opt-in, deterministic bootstrap package without manufacturing a query. Only explicitly curated + active Atomic Memory Artifacts and one caller-selected exact committed Handoff are eligible. Returned + historical text is untrusted data and grants no authority. A ready receipt remains pending until the host + records delivery. Reusing one stable event identity with different package-shaping fields is rejected with 422. + operationId: prepare_bootstrap_context + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BootstrapContextRequest" + responses: + "200": + description: A bounded package ready for host injection, or a normal empty/skipped result. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/BootstrapContext" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/context/bootstrap/receipts: + post: + tags: [context] + summary: Record a bootstrap delivery outcome + description: >- + Atomically claim one pending content-free receipt as injected or failed. A second injected claim is rejected + with 422, so concurrent lifecycle hooks cannot both inject the same package. A terminal outcome never changes. + operationId: record_bootstrap_delivery + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RecordBootstrapDeliveryRequest" + responses: + "200": + description: Current durable delivery state. + content: + application/json: + schema: + $ref: "#/components/schemas/BootstrapDeliveryReceipt" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" /v1/work/contracts/create: post: tags: [work] @@ -7978,6 +8051,71 @@ components: content_bytes: type: integer minimum: 0 + BootstrapContextItem: + type: object + additionalProperties: false + required: [kind, scope_id, artifact, content_digest, truncated] + properties: + kind: + $ref: "#/components/schemas/BootstrapContextItemKind" + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + truncated: + type: boolean + BootstrapDeliveryReceipt: + type: object + additionalProperties: false + required: [receipt_id, state] + properties: + receipt_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + state: + $ref: "#/components/schemas/BootstrapReceiptState" + BootstrapContext: + type: object + additionalProperties: false + required: [schema, status, reason, profile, content, content_bytes, package_digest, items, truncated, receipt] + properties: + schema: + $ref: "#/components/schemas/BootstrapContextSchema" + status: + $ref: "#/components/schemas/BootstrapContextStatus" + reason: + allOf: + - $ref: "#/components/schemas/BootstrapSkipReason" + nullable: true + profile: + $ref: "#/components/schemas/BootstrapContextProfile" + content: + type: string + nullable: true + content_bytes: + type: integer + minimum: 0 + package_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + nullable: true + items: + type: array + maxItems: 7 + items: + $ref: "#/components/schemas/BootstrapContextItem" + truncated: + type: boolean + receipt: + $ref: "#/components/schemas/BootstrapDeliveryReceipt" EntryChange: type: object additionalProperties: false @@ -9862,6 +10000,73 @@ components: description: Opt into current-Scope native code evidence. No index is built during preparation. assembly: $ref: "#/components/schemas/ContextAssembly" + bootstrap_receipt_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + description: >- + Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Atomic + Memory revisions from an injected same-Scope receipt are removed before candidate limiting and sufficiency + assessment; Artifacts whose bootstrap bodies were truncated remain eligible. + BootstrapContextRequest: + type: object + additionalProperties: false + required: [scope_id, lifecycle, integration] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + enabled: + type: boolean + default: false + profile: + allOf: + - $ref: "#/components/schemas/BootstrapContextProfile" + default: powercontext.scope-bootstrap.v1 + lifecycle: + $ref: "#/components/schemas/BootstrapContextLifecycle" + integration: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + event_id: + type: string + minLength: 1 + maxLength: 512 + pattern: '^[\x20-\x7E]+$' + description: >- + Optional stable host event identity, persisted only as a digest. Within the same Scope, integration, and + lifecycle, retries must keep the profile, max_bytes, and exact Handoff unchanged. + max_bytes: + type: integer + minimum: 512 + maximum: 8192 + default: 4096 + handoff: + $ref: "#/components/schemas/ArtifactReference" + description: Optional exact committed Handoff Revision in the current Scope. + RecordBootstrapDeliveryRequest: + type: object + additionalProperties: false + required: [scope_id, receipt_id, outcome] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + receipt_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + outcome: + type: string + enum: [injected, failed] ContextAssemblySection: type: object additionalProperties: false @@ -11121,6 +11326,27 @@ components: PreparedContextStatus: type: string enum: [ready, empty] + BootstrapContextSchema: + type: string + enum: [powercontext.bootstrap-context.v1] + BootstrapContextProfile: + type: string + enum: [powercontext.scope-bootstrap.v1] + BootstrapContextLifecycle: + type: string + enum: [startup, resume, clear, compact, restore, fork] + BootstrapContextStatus: + type: string + enum: [ready, empty, skipped] + BootstrapContextItemKind: + type: string + enum: [atomic-memory, handoff] + BootstrapReceiptState: + type: string + enum: [pending, injected, skipped, failed] + BootstrapSkipReason: + type: string + enum: [disabled, no_eligible_context, already_delivered, preparation_failed] EntryChangeOperation: type: string enum: [add, revise, deactivate, reactivate, compact] diff --git a/scripts/integration_manifest.py b/scripts/integration_manifest.py index dcc8e9dbdc..ee733d44ad 100644 --- a/scripts/integration_manifest.py +++ b/scripts/integration_manifest.py @@ -709,6 +709,7 @@ def _is_complete_hook(integration_id: str, event: str, scripts: set[Path]) -> bo ("codex", "SessionStart"): "session_binding.py", ("codex", "PreToolUse"): "bind_tools.py", ("codex", "Stop"): "token_savings.py", + ("claude-code", "SessionStart"): "session_start.py", }.get((integration_id, event)) return expected_script is not None and any( script.is_file() and script.name == expected_script for script in scripts diff --git a/src/powercontext/builtin/persistence/bootstrap_receipts.py b/src/powercontext/builtin/persistence/bootstrap_receipts.py new file mode 100644 index 0000000000..c64fdb2f1c --- /dev/null +++ b/src/powercontext/builtin/persistence/bootstrap_receipts.py @@ -0,0 +1,229 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Content-free persistence for bootstrap delivery receipts.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import datetime +from typing import cast + +from pydantic import TypeAdapter +from sqlalchemy import delete, insert, select, update +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from powercontext.builtin.persistence.codec import stored_bytes +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.tables import CONTEXT_BOOTSTRAP_RECEIPTS_TABLE +from powercontext.builtin.runtime.models import ( + BootstrapContextItem, + BootstrapContextLifecycle, + BootstrapContextProfile, + BootstrapReceiptState, + BootstrapSkipReason, +) + +_ITEMS = TypeAdapter(tuple[BootstrapContextItem, ...]) + + +@dataclass(frozen=True) +class StoredBootstrapReceipt: + receipt_id: str + scope_id: str + event_key: str | None + request_digest: str + integration: str + lifecycle: BootstrapContextLifecycle + profile: BootstrapContextProfile + state: BootstrapReceiptState + reason: BootstrapSkipReason | None + max_bytes: int + package_digest: str | None + content_bytes: int + truncated: bool + items: tuple[BootstrapContextItem, ...] + created_at: datetime + + +class BootstrapReceiptRepository: + """Reserve stable events and apply monotonic delivery outcomes.""" + + def __init__(self, database: AsyncDatabase) -> None: + self._database = database + + async def by_event(self, event_key: str) -> StoredBootstrapReceipt | None: + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).where( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.event_key == event_key + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _stored_receipt(row) + + async def create(self, receipt: StoredBootstrapReceipt) -> tuple[StoredBootstrapReceipt, bool]: + values = _values(receipt) + async with self._database.transaction() as connection: + if receipt.event_key is None: + await connection.execute(insert(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).values(**values)) + return receipt, True + if connection.dialect.name == "sqlite": + statement = ( + sqlite_insert(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE) + .values(**values) + .on_conflict_do_nothing(index_elements=["event_key"]) + ) + elif connection.dialect.name == "mysql": + statement = mysql_insert(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).values(**values).prefix_with("IGNORE") + else: + statement = insert(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).values(**values) + result = await connection.execute(statement) + if result.rowcount == 1: + return receipt, True + row = ( + ( + await connection.execute( + select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).where( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.event_key == receipt.event_key + ) + ) + ) + .mappings() + .one() + ) + return _stored_receipt(row), False + + async def delete_before(self, cutoff: datetime) -> int: + """Delete receipts outside the bounded retry/deduplication window.""" + + async with self._database.transaction() as connection: + result = await connection.execute( + delete(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).where(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.created_at < cutoff) + ) + return result.rowcount + + async def record_delivery( + self, + scope_id: str, + receipt_id: str, + outcome: BootstrapReceiptState, + ) -> tuple[StoredBootstrapReceipt | None, bool]: + if outcome not in {"injected", "failed", "skipped"}: + raise ValueError("invalid bootstrap delivery outcome") # noqa: TRY003 + async with self._database.transaction() as connection: + table = CONTEXT_BOOTSTRAP_RECEIPTS_TABLE + changed = await connection.execute( + update(table) + .where(table.c.scope_id == scope_id, table.c.receipt_id == receipt_id, table.c.state == "pending") + .values(state=outcome) + ) + row = ( + ( + await connection.execute( + select(table).where(table.c.scope_id == scope_id, table.c.receipt_id == receipt_id) + ) + ) + .mappings() + .one_or_none() + ) + if row is None: + return None, False + stored = _stored_receipt(row) + claimed = changed.rowcount == 1 + return (replace(stored, state=outcome) if claimed else stored), claimed + + async def injected_memory_keys(self, scope_id: str, receipt_id: str) -> frozenset[tuple[object, ...]]: + """Return fully delivered Atomic Memory revision identities from one injected package.""" + + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.state, + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c["items"], + ).where( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.scope_id == scope_id, + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.receipt_id == receipt_id, + ) + ) + ) + .mappings() + .one_or_none() + ) + if row is None or row["state"] != "injected": + return frozenset() + items = _load_items(row["items"], receipt_id) + return frozenset( + ( + item.scope_id, + item.artifact.artifact_id, + item.artifact.revision, + ) + for item in items + if item.kind == "atomic-memory" and not item.truncated + ) + + +def _values(receipt: StoredBootstrapReceipt) -> dict[str, object]: + return { + "receipt_id": receipt.receipt_id, + "scope_id": receipt.scope_id, + "event_key": receipt.event_key, + "request_digest": receipt.request_digest, + "integration": receipt.integration, + "lifecycle": receipt.lifecycle, + "profile": receipt.profile, + "state": receipt.state, + "reason": receipt.reason, + "max_bytes": receipt.max_bytes, + "package_digest": receipt.package_digest, + "content_bytes": receipt.content_bytes, + "truncated": receipt.truncated, + "items": _ITEMS.dump_json(receipt.items), + "created_at": receipt.created_at, + } + + +def _load_items(value: object, receipt_id: str) -> tuple[BootstrapContextItem, ...]: + return _ITEMS.validate_json(stored_bytes(value, column=f"bootstrap receipt {receipt_id} items"), strict=True) + + +def _stored_receipt(row: object) -> StoredBootstrapReceipt: + values = cast(dict[str, object], row) + receipt_id = cast(str, values["receipt_id"]) + return StoredBootstrapReceipt( + receipt_id=receipt_id, + scope_id=cast(str, values["scope_id"]), + event_key=cast(str | None, values["event_key"]), + request_digest=cast(str, values["request_digest"]), + integration=cast(str, values["integration"]), + lifecycle=cast(BootstrapContextLifecycle, values["lifecycle"]), + profile=cast(BootstrapContextProfile, values["profile"]), + state=cast(BootstrapReceiptState, values["state"]), + reason=cast(BootstrapSkipReason | None, values["reason"]), + max_bytes=cast(int, values["max_bytes"]), + package_digest=cast(str | None, values["package_digest"]), + content_bytes=cast(int, values["content_bytes"]), + truncated=cast(bool, values["truncated"]), + items=_load_items(values["items"], receipt_id), + created_at=cast(datetime, values["created_at"]), + ) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 69c5191fa4..cf0ae0d6ee 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -1166,6 +1166,35 @@ def _entry_text_type(): Column("reason", String(64), nullable=False), ) +CONTEXT_BOOTSTRAP_RECEIPTS_TABLE = Table( + "pc_context_bootstrap_receipts", + SHARED_METADATA, + Column("receipt_id", identity_string(64), primary_key=True), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + Column("event_key", identity_string(64), nullable=True), + Column("request_digest", identity_string(71), nullable=False), + Column("integration", identity_string(64), nullable=False), + Column("lifecycle", identity_string(16), nullable=False), + Column("profile", identity_string(64), nullable=False), + Column("state", identity_string(16), nullable=False), + Column("reason", identity_string(32), nullable=True), + Column("max_bytes", Integer, nullable=False), + Column("package_digest", identity_string(71), nullable=True), + Column("content_bytes", Integer, nullable=False), + Column("truncated", Boolean, nullable=False), + Column("items", _canonical_payload_type(), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), + UniqueConstraint("event_key", name="uq_pc_context_bootstrap_receipts_event"), + Index("ix_pc_context_bootstrap_receipts_created_at", "created_at"), + CheckConstraint( + "state IN ('pending', 'injected', 'skipped', 'failed')", + name="ck_pc_context_bootstrap_receipts_state", + ), + CheckConstraint("max_bytes >= 512 AND max_bytes <= 8192", name="ck_pc_context_bootstrap_receipts_budget"), + CheckConstraint("content_bytes >= 0", name="ck_pc_context_bootstrap_receipts_content_bytes"), +) + # The recurrence ledger is append-only: every correction is a new row, and the # only write path is the existing task-outcome incubation window. RECURRENCE_MATCH_TABLE = Table( @@ -1264,5 +1293,10 @@ def _entry_text_type(): + STATISTICS_TABLES + DECISION_OBSERVATION_TABLES + RECURRENCE_TABLES - + (ARTIFACT_TAGS_TABLE, DREAM_RUNS_TABLE, RECEIPT_MIGRATION_REVIEW_TABLE) + + ( + ARTIFACT_TAGS_TABLE, + DREAM_RUNS_TABLE, + RECEIPT_MIGRATION_REVIEW_TABLE, + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE, + ) ) diff --git a/src/powercontext/builtin/persistence/tags.py b/src/powercontext/builtin/persistence/tags.py index ee44b55be0..9353b900d0 100644 --- a/src/powercontext/builtin/persistence/tags.py +++ b/src/powercontext/builtin/persistence/tags.py @@ -204,68 +204,55 @@ async def query(self, scope_id: str, query: TagQuery, *, caller: str = "runtime" ).hexdigest() after = self._decode_cursor(query.cursor, binding) table = ARTIFACT_TAGS_TABLE + heads = ARTIFACT_HEADS_TABLE order = (table.c.family, table.c.target_type, table.c.artifact_id, table.c.target_id) items: list[TaggedTarget] = [] keys: list[tuple[str, ...]] = [] async with self._database.transaction() as connection: await _begin_read_snapshot(connection) - heads: dict[tuple[str, str], tuple[Artifact[Any], str]] = {} - while len(items) <= query.limit: - statement = ( - select(*order) - .where( - table.c.scope_id == scope_id, - table.c.tag_key_hash.in_(_key_hashes(query)), - table.c.tag_key.in_(query.keys), - table.c.family.in_(query.families), - table.c.target_type.in_(query.target_types), - tuple_(*order) > tuple_(*(literal(value) for value in after)), + statement = ( + select(*order, heads.c.revision) + .select_from( + table.join( + heads, + and_( + heads.c.scope_id == table.c.scope_id, + heads.c.family == table.c.family, + heads.c.artifact_id == table.c.artifact_id, + ), ) - .group_by(*order) - .having(func.count() == len(query.keys) if query.match == "all" else func.count() > 0) - .order_by(*order) - .limit(100) ) - rows = (await connection.execute(statement)).all() - for row in rows: - key = tuple(str(value) for value in row) - family, _target_type, artifact_id, _target_id = key - target = ArtifactTagTarget(family=cast(Any, family), artifact_id=artifact_id) - head_key = (family, artifact_id) - if head_key not in heads: - head_row = ( - await connection.execute( - select( - ARTIFACT_HEADS_TABLE.c.revision, - ARTIFACT_HEADS_TABLE.c.lifecycle_state, - ).where( - ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == family, - ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, - ) - ) - ).one_or_none() - if head_row is None: - continue - ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=head_row.revision) - heads[head_key] = ( - await self._artifacts.get(connection, scope_id, ref), - str(head_row.lifecycle_state), - ) - artifact, lifecycle = heads[head_key] - if not query.include_inactive and lifecycle != "active": - continue - reference = artifact.as_ref() - labels = await self._read(connection, scope_id, target) - items.append(TaggedTarget(**labels.model_dump(), reference=reference)) - keys.append(key) - if len(items) > query.limit: - break - if not rows or len(rows) < 100: - break - after = tuple(str(value) for value in rows[-1]) - cursor = self._encode_cursor(keys[query.limit - 1], binding) if len(items) > query.limit else None - return TagQueryPage(items=tuple(items[: query.limit]), next_cursor=cursor) + .where( + table.c.scope_id == scope_id, + table.c.tag_key_hash.in_(_key_hashes(query)), + table.c.tag_key.in_(query.keys), + table.c.family.in_(query.families), + table.c.target_type.in_(query.target_types), + tuple_(*order) > tuple_(*(literal(value) for value in after)), + ) + .group_by(*order, heads.c.revision) + .having(func.count() == len(query.keys) if query.match == "all" else func.count() > 0) + .order_by(*order) + .limit(query.limit + 1) + ) + if not query.include_inactive: + statement = statement.where(heads.c.lifecycle_state == "active") + rows = (await connection.execute(statement)).all() + artifacts: dict[tuple[str, str], Artifact[Any]] = {} + for row in rows[: query.limit]: + key = tuple(str(value) for value in row[:4]) + family, _target_type, artifact_id, _target_id = key + target = ArtifactTagTarget(family=cast(Any, family), artifact_id=artifact_id) + head_key = (family, artifact_id) + if head_key not in artifacts: + ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=int(row[4])) + artifacts[head_key] = await self._artifacts.get(connection, scope_id, ref) + reference = artifacts[head_key].as_ref() + labels = await self._read(connection, scope_id, target) + items.append(TaggedTarget(**labels.model_dump(), reference=reference)) + keys.append(key) + cursor = self._encode_cursor(keys[-1], binding) if len(rows) > query.limit else None + return TagQueryPage(items=tuple(items), next_cursor=cursor) async def _read( self, connection: AsyncConnection, scope_id: str, target: TagTarget, *, current=False diff --git a/src/powercontext/builtin/runtime/__init__.py b/src/powercontext/builtin/runtime/__init__.py index 3cf2bc71d2..3866f6ccdc 100644 --- a/src/powercontext/builtin/runtime/__init__.py +++ b/src/powercontext/builtin/runtime/__init__.py @@ -50,6 +50,7 @@ from powercontext.builtin.runtime.application import ( ArtifactApplication, AtomicMemoryRuntimeApplication, + BootstrapApplication, BuiltinRuntime, ExperienceApplication, ExternalSkillApplication, @@ -61,6 +62,7 @@ ScheduledSourceProcessor, ScopedArtifactApplication, ScopedAtomicMemoryApplication, + ScopedBootstrapApplication, ScopedExperienceApplication, ScopedExternalSkillApplication, ScopedHandoffApplication, @@ -137,6 +139,10 @@ ) from powercontext.builtin.runtime.models import ( ApproveArtifactCandidateRequest, + BootstrapContext, + BootstrapContextItem, + BootstrapContextRequest, + BootstrapDeliveryReceipt, CaptureSource, CommitConnectorCheckpoint, ConnectorCheckpointState, @@ -168,6 +174,7 @@ PreparedContext, ProposeExperienceRequest, ProposeSkillRequest, + RecordBootstrapDeliveryRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, @@ -236,6 +243,11 @@ "AtomicMemoryRuntimeApplication", "AtomicMemorySearchHit", "AtomicMemorySearchPage", + "BootstrapApplication", + "BootstrapContext", + "BootstrapContextItem", + "BootstrapContextRequest", + "BootstrapDeliveryReceipt", "BuiltinConfig", "BuiltinConfigurationError", "BuiltinRuntime", @@ -347,6 +359,7 @@ "RecallTokenMeasurement", "RecallTokenStatistics", "RecallTokenValue", + "RecordBootstrapDeliveryRequest", "RejectArtifactCandidateRequest", "RememberMemoryRequest", "RemoteIngestion", @@ -369,6 +382,7 @@ "ScopedArtifactApplication", "ScopedArtifactMerge", "ScopedAtomicMemoryApplication", + "ScopedBootstrapApplication", "ScopedDreamApplication", "ScopedExperienceApplication", "ScopedExternalSkillApplication", diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 8dbe0cfa0d..7c8b617626 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -17,15 +17,18 @@ from __future__ import annotations import asyncio +import json import logging from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractContextManager, asynccontextmanager, nullcontext from contextvars import ContextVar from dataclasses import dataclass, replace -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta +from hashlib import sha256 from pathlib import Path from time import perf_counter from typing import TYPE_CHECKING, Any, Protocol, TypeVar +from uuid import uuid4 from pydantic import BaseModel, JsonValue, ValidationError @@ -138,6 +141,10 @@ ArtifactGovernance, ArtifactLifecycleState, ) +from powercontext.builtin.persistence.bootstrap_receipts import ( + BootstrapReceiptRepository, + StoredBootstrapReceipt, +) from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.skill_publications import SkillPublication from powercontext.builtin.publication import ArtifactPublicationApplication @@ -165,11 +172,22 @@ from powercontext.builtin.runtime.artifact_merge import ArtifactMergeApplication from powercontext.builtin.runtime.artifact_search import ArtifactSearchService from powercontext.builtin.runtime.atomic_memory import AtomicMemorySearchHit +from powercontext.builtin.runtime.bootstrap_context import ( + BootstrapBuild, + BootstrapCandidate, + build_bootstrap_context, +) from powercontext.builtin.runtime.decision_model import DecisionModel from powercontext.builtin.runtime.errors import InvalidRuntimeRequestError, TopicMemoryProcessingUnavailableError from powercontext.builtin.runtime.extraction_diagnostics import ExtractionDiagnostics from powercontext.builtin.runtime.models import ( ApproveArtifactCandidateRequest, + BootstrapContext, + BootstrapContextItem, + BootstrapContextRequest, + BootstrapDeliveryReceipt, + BootstrapReceiptState, + BootstrapSkipReason, CaptureSource, CommitConnectorCheckpoint, ConnectorCheckpointState, @@ -192,6 +210,7 @@ PreparedContext, ProposeExperienceRequest, ProposeSkillRequest, + RecordBootstrapDeliveryRequest, RejectArtifactCandidateRequest, ResolveExternalSkillRequest, ReviewedCandidate, @@ -262,7 +281,13 @@ StatisticsPeriod, ) from powercontext.builtin.statistics.aggregation import aggregate_statistics -from powercontext.builtin.tags import ArtifactTagSet, TagFilter, TagQuery, TagQueryPage, TagTarget +from powercontext.builtin.tags import ( + ArtifactTagSet, + TagFilter, + TagQuery, + TagQueryPage, + TagTarget, +) from powercontext.builtin.work import ( HANDOFF_BOUNDARY_SOURCE_KIND, HANDOFF_RECEIPT_SOURCE_KIND, @@ -940,6 +965,15 @@ async def _prepare_build( # Expansion rounds read it so a repeat search does not re-embed; round 0 fills it. reuse: dict[str, MemoryQueryEmbedding | None] = {} topic_reuse: dict[str, MemoryQueryEmbedding | None] = {} + injected_memory_keys: frozenset[tuple[object, ...]] = frozenset() + if request.bootstrap_receipt_id is not None and self._runtime._bootstrap_receipts is not None: + receipts = self._runtime._bootstrap_receipts + cutoff = self._runtime._clock().astimezone(UTC) - _BOOTSTRAP_RECEIPT_RETENTION + await receipts.delete_before(cutoff) + injected_memory_keys = await receipts.injected_memory_keys( + self.scope_id, + request.bootstrap_receipt_id, + ) round_zero = await self._recall_round( request, @@ -949,6 +983,7 @@ async def _prepare_build( admission=None, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=injected_memory_keys, ) memory_candidates = list(round_zero.memory) experience_candidates = list(round_zero.experience) @@ -983,6 +1018,7 @@ async def _prepare_build( reuse=reuse, topic_reuse=topic_reuse, round_zero=round_zero, + excluded_memory=injected_memory_keys, ) memory_candidates = await self._available_atomic_candidates(memory_candidates) code = await self._code_candidates(request) if request.include_code else () @@ -1099,6 +1135,7 @@ async def _gated_recall_effort( # noqa: C901 - the bounded expansion loop is in reuse: dict[str, MemoryQueryEmbedding | None], topic_reuse: dict[str, MemoryQueryEmbedding | None], round_zero: _RecallRoundOutcome, + excluded_memory: frozenset[tuple[object, ...]], ) -> tuple[ list[PreparedMemoryCandidates], list[PreparedExperienceCandidates], @@ -1184,6 +1221,7 @@ async def _gated_recall_effort( # noqa: C901 - the bounded expansion loop is in admission=plan.admission, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=excluded_memory, ) memory_ranks: dict[Any, int] = {} for group in issued.memory: @@ -1329,6 +1367,7 @@ async def _recall_round( admission: AdmissionFloor | None, reuse: dict[str, MemoryQueryEmbedding | None], topic_reuse: dict[str, MemoryQueryEmbedding | None], + excluded_memory: frozenset[tuple[object, ...]], ) -> _RecallRoundOutcome: memory_candidates: list[PreparedMemoryCandidates] = [] experience_candidates: list[PreparedExperienceCandidates] = [] @@ -1337,17 +1376,33 @@ async def _recall_round( generation_calls = 0 memory_recoverable = False for scope_id in scope_ids: + memory_limit = ( + builder.memory_candidate_limit + sum(identity[0] == scope_id for identity in excluded_memory) + if MEMORY_FAMILY in families + else 0 + ) outcome = await self._recall_scope( scope_id, request, - memory_limit=builder.memory_candidate_limit if MEMORY_FAMILY in families else 0, + memory_limit=memory_limit, experience_limit=builder.experience_candidate_limit if EXPERIENCE_FAMILY in families else 0, admission=admission, reuse=reuse.get(scope_id), allow_embedding=scope_id not in reuse or reuse[scope_id] is not None, ) memory_recoverable |= outcome.memory_recoverable - memory_candidates.append(outcome.memory) + memory_candidates.append( + replace( + outcome.memory, + hits=tuple( + hit + for hit in outcome.memory.hits + if _bootstrap_memory_identity(outcome.memory.scope_id, hit) not in excluded_memory + ), + ) + if excluded_memory + else outcome.memory + ) experience_candidates.append(outcome.experience) reuse[scope_id] = outcome.memory_query_embedding admissions.extend( @@ -1688,6 +1743,12 @@ def _memory_identity(scope_id: str, hit: AtomicMemorySearchHit) -> tuple[str, st return (scope_id, hit.hit.artifact_ref.artifact_id, hit.hit.artifact_ref.revision, hit.hit.state_version) +def _bootstrap_memory_identity(scope_id: str, hit: AtomicMemorySearchHit) -> tuple[str, str, int]: + """Identify one exact Atomic Memory content revision.""" + + return (scope_id, hit.hit.artifact_ref.artifact_id, hit.hit.artifact_ref.revision) + + def _experience_identity(scope_id: str, hit: ExperienceSearchHit) -> tuple[str, str, int]: return (scope_id, hit.artifact_ref.artifact_id, hit.artifact_ref.revision) @@ -1728,6 +1789,424 @@ def for_scope(self, scope_id: str, /) -> ScopedContextApplication: return ScopedContextApplication(self._runtime, scope_id) +_BOOTSTRAP_TAG = "bootstrap-context" +_BOOTSTRAP_MEMORY_LIMIT = 6 +_BOOTSTRAP_CONTEXT_REFERENCE_LIMIT = 8 +_BOOTSTRAP_TAG_SCAN_LIMIT = 32 +_BOOTSTRAP_RECEIPT_RETENTION = timedelta(days=30) +_SECRET_MEMORY_KIND_MARKERS = ( + "secret", + "credential", + "password", + "token", + "apikey", + "accesskey", + "privatekey", +) + + +class ScopedBootstrapApplication: + """Prepare and account for one Scope's lifecycle bootstrap context.""" + + def __init__(self, runtime: BuiltinRuntime, scope_id: str) -> None: + self._runtime = runtime + self.scope_id = validate_scope_id(scope_id) + + async def prepare( + self, + request: BootstrapContextRequest, + /, + *, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None = None, + ) -> BootstrapContext: + repository = self._repository() + async with self._runtime._scope_operation(self.scope_id) as scope: + scope_ids = (self.scope_id, *scope.context_references[:_BOOTSTRAP_CONTEXT_REFERENCE_LIMIT]) + event_key = _bootstrap_event_key(self.scope_id, request) + await self._delete_expired_receipts(request, event_key, repository) + if not request.enabled: + return await self._disabled_result(request, event_key, repository, authorize_scopes) + existing = await self._existing_event_result( + request, + event_key, + scope_ids, + repository, + authorize_scopes, + ) + if existing is not None: + return existing + if authorize_scopes is not None: + await authorize_scopes(scope_ids) + candidates = await self._select_candidates(request, scope_ids) + build = build_bootstrap_context(candidates, request.max_bytes) + if build.content is None: + return await self._empty_result( + request, + event_key, + build, + scope_ids, + repository, + authorize_scopes, + ) + + stored, created = await repository.create( + self._stored_receipt( + request, + event_key=event_key, + state="pending", + content=build.content, + items=build.items, + truncated=build.truncated, + ) + ) + if not created: + self._require_matching_request(stored, request) + await self._authorize_stored_result(stored, scope_ids, authorize_scopes) + return await self._existing_result(stored) + return _ready_bootstrap(stored, build.content) + + async def _delete_expired_receipts( + self, + request: BootstrapContextRequest, + event_key: str | None, + repository: BootstrapReceiptRepository, + ) -> None: + if request.enabled or event_key is not None: + cutoff = self._runtime._clock().astimezone(UTC) - _BOOTSTRAP_RECEIPT_RETENTION + await repository.delete_before(cutoff) + + async def _existing_event_result( + self, + request: BootstrapContextRequest, + event_key: str | None, + scope_ids: tuple[str, ...], + repository: BootstrapReceiptRepository, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None, + ) -> BootstrapContext | None: + if event_key is None: + return None + existing = await repository.by_event(event_key) + if existing is None: + return None + self._require_matching_request(existing, request) + await self._authorize_stored_result(existing, scope_ids, authorize_scopes) + return await self._existing_result(existing) + + async def _authorize_stored_result( + self, + stored: StoredBootstrapReceipt, + scope_ids: tuple[str, ...], + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None, + ) -> None: + if authorize_scopes is None: + return + stored_scope_ids = tuple(dict.fromkeys((*scope_ids, *(item.scope_id for item in stored.items)))) + await authorize_scopes(stored_scope_ids) + + async def _empty_result( + self, + request: BootstrapContextRequest, + event_key: str | None, + build: BootstrapBuild, + scope_ids: tuple[str, ...], + repository: BootstrapReceiptRepository, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None, + ) -> BootstrapContext: + candidate = self._stored_receipt( + request, + event_key=event_key, + state="skipped", + reason="no_eligible_context", + truncated=build.truncated, + ) + if event_key is None: + stored = candidate + else: + stored, created = await repository.create(candidate) + if not created: + self._require_matching_request(stored, request) + await self._authorize_stored_result(stored, scope_ids, authorize_scopes) + return await self._existing_result(stored) + return BootstrapContext( + status="empty", + reason="no_eligible_context", + content=None, + content_bytes=0, + truncated=build.truncated, + receipt=_delivery_receipt(stored), + ) + + async def _disabled_result( + self, + request: BootstrapContextRequest, + event_key: str | None, + repository: BootstrapReceiptRepository, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None, + ) -> BootstrapContext: + if authorize_scopes is not None: + await authorize_scopes((self.scope_id,)) + stored = await repository.by_event(event_key) if event_key is not None else None + if stored is None: + candidate = self._stored_receipt(request, event_key=event_key, state="skipped", reason="disabled") + if event_key is None: + stored = candidate + else: + stored, _ = await repository.create(candidate) + if stored.state == "pending": + stored, _ = await repository.record_delivery(self.scope_id, stored.receipt_id, "skipped") + if stored is None: + raise InvalidRuntimeRequestError("bootstrap-receipt") + return self._terminal_result(stored, reason="disabled" if stored.state == "skipped" else "already_delivered") + + async def record_delivery(self, request: RecordBootstrapDeliveryRequest, /) -> BootstrapDeliveryReceipt: + async with self._runtime._scope_operation(self.scope_id): + stored, claimed = await self._repository().record_delivery( + self.scope_id, request.receipt_id, request.outcome + ) + if stored is None: + raise InvalidRuntimeRequestError("bootstrap-receipt") + if request.outcome == "injected" and not claimed: + raise InvalidRuntimeRequestError("bootstrap-delivery-already-claimed") + return _delivery_receipt(stored) + + async def _select_candidates( + self, + request: BootstrapContextRequest, + scope_ids: tuple[str, ...], + ) -> tuple[BootstrapCandidate, ...]: + records = self._runtime._records() + candidates: list[BootstrapCandidate] = [] + if request.handoff is not None: + handoff = await records.get_artifact_revision( + self.scope_id, + "handoff", + request.handoff.artifact_id, + request.handoff.revision, + ) + candidates.append( + BootstrapCandidate( + item=BootstrapContextItem( + kind="handoff", + scope_id=self.scope_id, + artifact=request.handoff, + content_digest=handoff.content_digest, + ), + content=json.dumps(handoff.content, ensure_ascii=False, indent=2, sort_keys=True), + ) + ) + + remaining = _BOOTSTRAP_MEMORY_LIMIT + for scope_id in scope_ids: + if remaining == 0: + break + page = await records.query_tags( + scope_id, + TagQuery( + tags=(_BOOTSTRAP_TAG,), + families=("atomic-memory",), + target_types=("artifact",), + include_inactive=False, + limit=_BOOTSTRAP_TAG_SCAN_LIMIT, + ), + caller="bootstrap-context", + ) + for tagged in page.items: + reference = tagged.reference + record = await records.get_artifact_revision( + scope_id, + "atomic-memory", + reference.artifact_id, + reference.revision, + ) + memory = AtomicMemoryContent.model_validate(record.content) + if _secret_memory_kind(memory.kind): + continue + candidates.append( + BootstrapCandidate( + item=BootstrapContextItem( + kind="atomic-memory", + scope_id=scope_id, + artifact=reference, + content_digest=record.content_digest, + ), + content=f"Kind: {json.dumps(memory.kind, ensure_ascii=True)}\nText:\n{memory.text}", + ) + ) + remaining -= 1 + if remaining == 0: + break + return tuple(candidates) + + async def _existing_result(self, stored: StoredBootstrapReceipt) -> BootstrapContext: + if stored.scope_id != self.scope_id or stored.state != "pending": + return self._terminal_result(stored, reason="already_delivered") + try: + candidates = tuple([await self._candidate_from_item(item) for item in stored.items]) + build = build_bootstrap_context(candidates, stored.max_bytes) + content = _require_reconstructed_bootstrap(stored, build.content, build) + except Exception as error: + log_safely( + logger, + logging.ERROR, + "Bootstrap receipt reconstruction failed", + exc_info=error, + extra={"event": "context.bootstrap.reconstruct_failed", "outcome": "failure", "unit": "context"}, + ) + failed, _ = await self._repository().record_delivery(self.scope_id, stored.receipt_id, "failed") + terminal = failed or stored + if terminal.state == "pending": + raise InvalidRuntimeRequestError("bootstrap-receipt") from error + reason: BootstrapSkipReason = "preparation_failed" if terminal.state == "failed" else "already_delivered" + return self._terminal_result(terminal, reason=reason) + return _ready_bootstrap(stored, content) + + async def _candidate_from_item(self, item: BootstrapContextItem) -> BootstrapCandidate: + records = self._runtime._records() + if item.kind == "handoff": + record = await records.get_artifact_revision( + item.scope_id, + item.artifact.family, + item.artifact.artifact_id, + item.artifact.revision, + ) + if record.content_digest != item.content_digest: + raise ValueError("bootstrap Handoff digest changed") # noqa: TRY003 + content = json.dumps(record.content, ensure_ascii=False, indent=2, sort_keys=True) + else: + record = await records.get_artifact_revision( + item.scope_id, + "atomic-memory", + item.artifact.artifact_id, + item.artifact.revision, + ) + if record.content_digest != item.content_digest: + raise ValueError("bootstrap Atomic Memory digest changed") # noqa: TRY003 + memory = AtomicMemoryContent.model_validate(record.content) + content = f"Kind: {json.dumps(memory.kind, ensure_ascii=True)}\nText:\n{memory.text}" + return BootstrapCandidate(item=item.model_copy(update={"truncated": False}), content=content) + + def _stored_receipt( + self, + request: BootstrapContextRequest, + *, + event_key: str | None, + state: BootstrapReceiptState, + reason: BootstrapSkipReason | None = None, + content: str | None = None, + items: tuple[BootstrapContextItem, ...] = (), + truncated: bool = False, + ) -> StoredBootstrapReceipt: + encoded = b"" if content is None else content.encode("utf-8") + return StoredBootstrapReceipt( + receipt_id=f"bcr_{uuid4().hex}", + scope_id=self.scope_id, + event_key=event_key, + request_digest=_bootstrap_request_digest(request), + integration=request.integration, + lifecycle=request.lifecycle, + profile=request.profile, + state=state, + reason=reason, + max_bytes=request.max_bytes, + package_digest=None if content is None else _package_digest(content), + content_bytes=len(encoded), + truncated=truncated, + items=items, + created_at=self._runtime._clock(), + ) + + @staticmethod + def _require_matching_request(stored: StoredBootstrapReceipt, request: BootstrapContextRequest) -> None: + if stored.request_digest != _bootstrap_request_digest(request): + raise InvalidRuntimeRequestError("bootstrap-event-request-mismatch") + + def _terminal_result(self, stored: StoredBootstrapReceipt, *, reason: BootstrapSkipReason) -> BootstrapContext: + return BootstrapContext( + status="skipped", + reason=reason, + profile=stored.profile, + content=None, + content_bytes=0, + truncated=False, + receipt=_delivery_receipt(stored), + ) + + def _repository(self) -> BootstrapReceiptRepository: + if self._runtime._bootstrap_receipts is None: + raise _RuntimeStateError("bootstrap-receipts") + return self._runtime._bootstrap_receipts + + +class BootstrapApplication: + """Select scoped bootstrap operations.""" + + def __init__(self, runtime: BuiltinRuntime) -> None: + self._runtime = runtime + + def for_scope(self, scope_id: str, /) -> ScopedBootstrapApplication: + return ScopedBootstrapApplication(self._runtime, scope_id) + + +def _bootstrap_event_key(scope_id: str, request: BootstrapContextRequest) -> str | None: + if request.event_id is None: + return None + value = "\0".join((scope_id, request.integration, request.lifecycle, request.event_id)) + return sha256(value.encode()).hexdigest() + + +def _secret_memory_kind(kind: str) -> bool: + compact = "".join(character for character in kind.casefold() if character.isalnum()) + return any(marker in compact for marker in _SECRET_MEMORY_KIND_MARKERS) + + +def _bootstrap_request_digest(request: BootstrapContextRequest) -> str: + payload = { + "handoff": None if request.handoff is None else request.handoff.model_dump(mode="json", by_alias=True), + "integration": request.integration, + "lifecycle": request.lifecycle, + "max_bytes": request.max_bytes, + "profile": request.profile, + } + encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode() + return "sha256:" + sha256(encoded).hexdigest() + + +def _package_digest(content: str) -> str: + return "sha256:" + sha256(content.encode("utf-8")).hexdigest() + + +def _require_reconstructed_bootstrap( + stored: StoredBootstrapReceipt, + content: str | None, + build: BootstrapBuild, +) -> str: + if ( + content is None + or build.items != stored.items + or len(content.encode("utf-8")) != stored.content_bytes + or _package_digest(content) != stored.package_digest + ): + raise ValueError("bootstrap receipt cannot be reconstructed") # noqa: TRY003 + return content + + +def _delivery_receipt(stored: StoredBootstrapReceipt) -> BootstrapDeliveryReceipt: + return BootstrapDeliveryReceipt(receipt_id=stored.receipt_id, state=stored.state) + + +def _ready_bootstrap(stored: StoredBootstrapReceipt, content: str) -> BootstrapContext: + return BootstrapContext( + status="ready", + profile=stored.profile, + content=content, + content_bytes=stored.content_bytes, + package_digest=stored.package_digest, + items=stored.items, + truncated=stored.truncated, + receipt=_delivery_receipt(stored), + ) + + class ScopedExperienceApplication: """Propose and exactly read Experience Artifacts in one scope.""" @@ -2978,6 +3457,7 @@ def __init__( remote_skill_distribution: RemoteSkillDistributionService | None = None, statistics_service: StatisticsServiceFactory | None = None, record_service: RecordService | None = None, + bootstrap_receipts: BootstrapReceiptRepository | None = None, atomic_memory_application=None, artifact_merge_application: ArtifactMergeApplication | None = None, prompt_service: PromptService | None = None, @@ -3048,6 +3528,7 @@ def __init__( self._remote_skill_distribution = remote_skill_distribution self._statistics_service = statistics_service self._record_service = record_service + self._bootstrap_receipts = bootstrap_receipts self.atomic_memory = ( None if atomic_memory_application is None @@ -3092,6 +3573,7 @@ def __init__( self.ingestion = RemoteIngestionApplication(self, remote_ingestion) self.code = CodeApplication(self, code_service or CodeService(CodeConfig())) self.context = ContextApplication(self) + self.bootstrap = BootstrapApplication(self) self.experience = ExperienceApplication(self) self.artifacts = ArtifactApplication(self) self.dream = DreamApplication(self) diff --git a/src/powercontext/builtin/runtime/bootstrap_context.py b/src/powercontext/builtin/runtime/bootstrap_context.py new file mode 100644 index 0000000000..f176505b11 --- /dev/null +++ b/src/powercontext/builtin/runtime/bootstrap_context.py @@ -0,0 +1,165 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pure selection and rendering for bounded lifecycle bootstrap context.""" + +from __future__ import annotations + +import json +import unicodedata +from collections.abc import Sequence +from dataclasses import dataclass, replace + +from powercontext.builtin.runtime.models import BootstrapContextItem +from powercontext.builtin.runtime.prepared_text import TRUST_POLICY + +_BEGIN_MARKER = "BEGIN_POWERCONTEXT_BOOTSTRAP_CONTEXT_V1" +_END_MARKER = "END_POWERCONTEXT_BOOTSTRAP_CONTEXT_V1" +_MAX_ITEM_BODY_BYTES = 2000 +_MIN_TRUNCATED_BODY_BYTES = 64 +_TRUNCATION_MARKER = "…" + + +@dataclass(frozen=True) +class BootstrapCandidate: + """Exact public metadata plus untrusted text used only for the current build.""" + + item: BootstrapContextItem + content: str + + +@dataclass(frozen=True) +class BootstrapBuild: + content: str | None + items: tuple[BootstrapContextItem, ...] = () + truncated: bool = False + + +def build_bootstrap_context(candidates: Sequence[BootstrapCandidate], max_bytes: int) -> BootstrapBuild: + """Fit complete citations and the longest safe body prefixes into one UTF-8 budget.""" + + included: list[BootstrapCandidate] = [] + omitted = False + for candidate in candidates[:7]: + fitted = _fit_candidate(included, candidate, max_bytes) + if fitted is None: + omitted = True + continue + included.append(fitted) + if not included: + return BootstrapBuild(content=None, truncated=omitted) + content = _render(included) + if len(content.encode("utf-8")) > max_bytes: + raise RuntimeError("bootstrap renderer exceeded its byte budget") # noqa: TRY003 + items = tuple(candidate.item for candidate in included) + return BootstrapBuild( + content=content, + items=items, + truncated=omitted or any(item.truncated for item in items), + ) + + +def _fit_candidate( + included: Sequence[BootstrapCandidate], + candidate: BootstrapCandidate, + max_bytes: int, +) -> BootstrapCandidate | None: + chunks = _body_chunks(candidate.content) + capped = _longest_prefix(chunks, _MAX_ITEM_BODY_BYTES) + complete_truncated = len(capped) < len(chunks) + if complete_truncated: + capped = _longest_prefix(chunks, _MAX_ITEM_BODY_BYTES - len(_TRUNCATION_MARKER.encode("utf-8"))) + complete = replace( + candidate, + item=candidate.item.model_copy(update={"truncated": complete_truncated}), + content="".join(capped) + (_TRUNCATION_MARKER if complete_truncated else ""), + ) + if _fits((*included, complete), max_bytes): + return complete + + lower, upper = 0, len(capped) + best: BootstrapCandidate | None = None + while lower <= upper: + middle = (lower + upper) // 2 + shortened = replace( + candidate, + item=candidate.item.model_copy(update={"truncated": True}), + content="".join(capped[:middle]) + _TRUNCATION_MARKER, + ) + if _fits((*included, shortened), max_bytes): + best = shortened + lower = middle + 1 + else: + upper = middle - 1 + if best is None or len(best.content.encode("utf-8")) < _MIN_TRUNCATED_BODY_BYTES: + return None + return best + + +def _longest_prefix(chunks: Sequence[str], max_bytes: int) -> tuple[str, ...]: + used = 0 + selected: list[str] = [] + for chunk in chunks: + size = len(chunk.encode("utf-8")) + if used + size > max_bytes: + break + selected.append(chunk) + used += size + return tuple(selected) + + +def _body_chunks(content: str) -> tuple[str, ...]: + normalized = content.replace("\r\n", "\n").replace("\r", "\n").replace("\u2028", "\n").replace("\u2029", "\n") + return tuple( + _visible_escape(character) + if character != "\n" and unicodedata.category(character) in {"Cc", "Cf"} + else character + for character in normalized + ) + + +def _visible_escape(character: str) -> str: + codepoint = ord(character) + return f"\\u{codepoint:04x}" if codepoint <= 0xFFFF else f"\\U{codepoint:08x}" + + +def _fits(candidates: Sequence[BootstrapCandidate], max_bytes: int) -> bool: + return len(_render(candidates).encode("utf-8")) <= max_bytes + + +def _quoted(value: str) -> str: + return json.dumps(value, ensure_ascii=True) + + +def _render(candidates: Sequence[BootstrapCandidate]) -> str: + parts = ["# PowerContext bootstrap context", TRUST_POLICY, _BEGIN_MARKER] + for number, candidate in enumerate(candidates, start=1): + item = candidate.item + artifact = item.artifact + title = "Committed Handoff" if item.kind == "handoff" else "Curated Atomic Memory" + metadata = [ + f"Scope: {_quoted(item.scope_id)}", + f"Artifact: family={_quoted(artifact.family)}, id={_quoted(artifact.artifact_id)}, revision={artifact.revision}", + ] + metadata.extend(( + f"Content digest: {_quoted(item.content_digest)}", + f"Truncated: {'yes' if item.truncated else 'no'}", + )) + parts.extend(( + f"## {title} {number}", + "\n".join(f" {line}" for line in metadata), + "\n".join(f"> {line}" for line in candidate.content.split("\n")), + )) + parts.append(_END_MARKER) + return "\n\n".join(parts) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index b8015b896c..b2c0cd1b99 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -100,6 +100,7 @@ assert_atomic_memory_migration_ready, ensure_supported_atomic_memory_schema, ) +from powercontext.builtin.persistence.bootstrap_receipts import BootstrapReceiptRepository from powercontext.builtin.persistence.experience_index import ensure_artifact_head_searchable_text from powercontext.builtin.persistence.oceanbase.atomic_memory_index import OceanBaseAtomicMemoryIndex from powercontext.builtin.persistence.oceanbase.experience_index import OceanBaseExperienceFTSIndex @@ -748,6 +749,7 @@ async def run_profile(scope_id, high): remote_skill_distribution=contexts.remote_skill_distribution(), statistics_service=contexts.statistics, record_service=contexts.records, + bootstrap_receipts=BootstrapReceiptRepository(contexts.database), atomic_memory_application=contexts.atomic_memory, artifact_merge_application=_artifact_merge_application(contexts, config.runtime), prompt_service=contexts.prompts, diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index e55a4109c5..91ee83362a 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -18,6 +18,7 @@ from collections.abc import Mapping from datetime import datetime +from hashlib import sha256 from typing import Annotated, Any, Literal, TypeAlias from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator @@ -54,9 +55,22 @@ PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] PreparedContextStatus: TypeAlias = Literal["ready", "empty"] +BootstrapContextSchema: TypeAlias = Literal["powercontext.bootstrap-context.v1"] +BootstrapContextProfile: TypeAlias = Literal["powercontext.scope-bootstrap.v1"] +BootstrapContextLifecycle: TypeAlias = Literal["startup", "resume", "clear", "compact", "restore", "fork"] +BootstrapContextStatus: TypeAlias = Literal["ready", "empty", "skipped"] +BootstrapReceiptState: TypeAlias = Literal["pending", "injected", "skipped", "failed"] +BootstrapSkipReason: TypeAlias = Literal[ + "disabled", + "no_eligible_context", + "already_delivered", + "preparation_failed", +] ReviewedProposal: TypeAlias = ExperienceContent | SkillContent | ProfileCandidateProposal PREPARED_CONTEXT_SCHEMA: PreparedContextSchema = "powercontext.prepared-context.v1" +BOOTSTRAP_CONTEXT_SCHEMA: BootstrapContextSchema = "powercontext.bootstrap-context.v1" +BOOTSTRAP_CONTEXT_PROFILE: BootstrapContextProfile = "powercontext.scope-bootstrap.v1" class _PreparedContextModel(BaseModel): @@ -268,6 +282,7 @@ class PrepareContextRequest(_PreparedContextModel): query: Annotated[str, Field(min_length=1, max_length=8192)] max_bytes: Annotated[int, Field(ge=512, le=32768)] = 8000 assembly: ContextAssembly | None = None + bootstrap_receipt_id: Annotated[str, Field(min_length=1, max_length=64)] | None = None include_code: bool = Field(default=False, strict=True) @model_validator(mode="before") @@ -284,6 +299,13 @@ def validate_query(cls, value: str) -> str: raise ValueError("query must contain non-whitespace content") # noqa: TRY003 return value + @field_validator("bootstrap_receipt_id") + @classmethod + def validate_bootstrap_receipt_id(cls, value: str | None) -> str | None: + if value is not None and not all("\x21" <= character <= "\x7e" for character in value): + raise ValueError("bootstrap_receipt_id must contain only visible ASCII") # noqa: TRY003 + return value + class PreparedContext(_PreparedContextModel): """Ephemeral context ready for direct injection into one Agent turn.""" @@ -306,6 +328,116 @@ def validate_content(self) -> PreparedContext: return self +class BootstrapContextRequest(_PreparedContextModel): + """Prepare curated context for one host lifecycle boundary without a fake query.""" + + enabled: bool = False + profile: BootstrapContextProfile = BOOTSTRAP_CONTEXT_PROFILE + lifecycle: BootstrapContextLifecycle + integration: Annotated[str, Field(min_length=1, max_length=64)] + event_id: Annotated[str, Field(min_length=1, max_length=512)] | None = None + max_bytes: Annotated[int, Field(ge=512, le=8192)] = 4096 + handoff: ArtifactRef | None = None + + @field_validator("integration") + @classmethod + def validate_integration(cls, value: str) -> str: + if not all("\x21" <= character <= "\x7e" for character in value): + raise ValueError("integration must contain only visible ASCII") # noqa: TRY003 + return value + + @field_validator("event_id") + @classmethod + def validate_event_id(cls, value: str | None) -> str | None: + if value is not None and not all("\x20" <= character <= "\x7e" for character in value): + raise ValueError("event_id must contain only printable ASCII") # noqa: TRY003 + return value + + @field_validator("handoff") + @classmethod + def validate_handoff(cls, value: ArtifactRef | None) -> ArtifactRef | None: + if value is not None and value.family != "handoff": + raise ValueError("bootstrap handoff must use the handoff family") # noqa: TRY003 + return value + + +class BootstrapContextItem(_PreparedContextModel): + """One delivered item pinned to exact authoritative content.""" + + kind: Literal["atomic-memory", "handoff"] + scope_id: Annotated[str, Field(min_length=1, max_length=256)] + artifact: ArtifactRef + content_digest: Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] + truncated: bool = False + + @model_validator(mode="after") + def validate_reference(self) -> BootstrapContextItem: + if self.artifact.family != self.kind: + raise ValueError("bootstrap item kind must match its Artifact family") # noqa: TRY003 + return self + + +class BootstrapDeliveryReceipt(_PreparedContextModel): + """Content-free durable delivery state exposed to a host integration.""" + + receipt_id: Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[\x21-\x7E]+$")] + state: BootstrapReceiptState + + +class BootstrapContext(_PreparedContextModel): + """A final bounded bootstrap package or a normal non-delivery result.""" + + schema_version: BootstrapContextSchema = Field(default=BOOTSTRAP_CONTEXT_SCHEMA, alias="schema") + status: BootstrapContextStatus + reason: BootstrapSkipReason | None = None + profile: BootstrapContextProfile = BOOTSTRAP_CONTEXT_PROFILE + content: str | None + content_bytes: Annotated[int, Field(ge=0)] + package_digest: Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] | None = None + items: Annotated[tuple[BootstrapContextItem, ...], Field(max_length=7, strict=False)] = () + truncated: bool = False + receipt: BootstrapDeliveryReceipt + + @model_validator(mode="after") + def validate_result(self) -> BootstrapContext: + if self.status == "ready": + if ( + self.reason is not None + or self.receipt.state != "pending" + or self.content is None + or not self.content.strip() + or self.package_digest is None + or not self.items + or len(self.content.encode("utf-8")) != self.content_bytes + or self.package_digest != f"sha256:{sha256(self.content.encode('utf-8')).hexdigest()}" + ): + raise ValueError("ready bootstrap context is incomplete") # noqa: TRY003 + return self + if self.content is not None or self.content_bytes != 0 or self.package_digest is not None or self.items: + raise ValueError("non-ready bootstrap context must not contain package data") # noqa: TRY003 + if self.status == "empty" and self.reason != "no_eligible_context": + raise ValueError("empty bootstrap context requires the no-content reason") # noqa: TRY003 + if self.status == "empty" and self.receipt.state != "skipped": + raise ValueError("empty bootstrap context requires a skipped receipt") # noqa: TRY003 + if self.status == "skipped" and self.reason is None: + raise ValueError("skipped bootstrap context requires a reason") # noqa: TRY003 + if self.status == "skipped" and ( + self.reason == "no_eligible_context" + or (self.reason == "disabled" and self.receipt.state != "skipped") + or (self.reason == "preparation_failed" and self.receipt.state != "failed") + or (self.reason == "already_delivered" and self.receipt.state == "pending") + ): + raise ValueError("skipped bootstrap context has an inconsistent receipt") # noqa: TRY003 + return self + + +class RecordBootstrapDeliveryRequest(_PreparedContextModel): + """Finalize one pending delivery receipt without accepting content or error text.""" + + receipt_id: Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[\x21-\x7E]+$")] + outcome: Literal["injected", "failed"] + + class ProposeExperienceRequest(BaseModel): """Submit a complete Experience proposal with exact evidence.""" diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 0f2fa8b5ab..6c6c81ee42 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -49,6 +49,9 @@ ArtifactPublication, ArtifactRevision, ArtifactRevisionPage, + BootstrapContext, + BootstrapContextRequest, + BootstrapDeliveryReceipt, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -146,6 +149,7 @@ ReadinessResponse, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, + RecordBootstrapDeliveryRequest, RecordRemoteSkillReceiptRequest, RecordSkillUsageRequest, RecordTaskOutcomeRequest, @@ -277,6 +281,7 @@ LIST_REMOTE_SKILL_TARGETS, LIST_SCOPES, LIST_SOURCES, + PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, PREPARE_HANDOFF, PREPARE_HANDOFF_HINT, @@ -289,6 +294,7 @@ QUERY_ARTIFACT_TAGS, QUERY_CODE, RECONCILE_REMOTE_SKILLS, + RECORD_BOOTSTRAP_DELIVERY, RECORD_REMOTE_SKILL_RECEIPT, RECORD_SKILL_USAGE, RECORD_TASK_OUTCOME, @@ -992,6 +998,19 @@ async def prepare_context(self, request: PrepareContextRequest) -> PreparedConte return await self._request(PREPARE_CONTEXT, request) + async def prepare_bootstrap_context(self, request: BootstrapContextRequest) -> BootstrapContext: + """Prepare curated bounded context for one Agent lifecycle boundary.""" + + return await self._request(PREPARE_BOOTSTRAP_CONTEXT, request) + + async def record_bootstrap_delivery( + self, + request: RecordBootstrapDeliveryRequest, + ) -> BootstrapDeliveryReceipt: + """Finalize one content-free bootstrap delivery receipt.""" + + return await self._request(RECORD_BOOTSTRAP_DELIVERY, request) + async def prepare_handoff(self, request: PrepareHandoffRequest) -> HandoffDraft: """Generate one inspectable Handoff Draft from exact evidence.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 9d810d3b6f..2ab3ed23e8 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -85,6 +85,17 @@ AtomicMemoryWriteContent, AuthorizationNote, BaseArtifactFamily, + BootstrapContext, + BootstrapContextItem, + BootstrapContextItemKind, + BootstrapContextLifecycle, + BootstrapContextProfile, + BootstrapContextRequest, + BootstrapContextSchema, + BootstrapContextStatus, + BootstrapDeliveryReceipt, + BootstrapReceiptState, + BootstrapSkipReason, BuiltinPromptInstructions, CandidateFamily, CandidateFamilyCount, @@ -311,6 +322,7 @@ ReceiverReadinessCheckStatus, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, + RecordBootstrapDeliveryRequest, RecordRemoteSkillReceiptRequest, RecordSkillUsageRequest, RecordTaskOutcomeRequest, @@ -515,6 +527,17 @@ "AtomicMemoryWriteContent", "AuthorizationNote", "BaseArtifactFamily", + "BootstrapContext", + "BootstrapContextItem", + "BootstrapContextItemKind", + "BootstrapContextLifecycle", + "BootstrapContextProfile", + "BootstrapContextRequest", + "BootstrapContextSchema", + "BootstrapContextStatus", + "BootstrapDeliveryReceipt", + "BootstrapReceiptState", + "BootstrapSkipReason", "BuiltinPromptInstructions", "CandidateFamily", "CandidateFamilyCount", @@ -741,6 +764,7 @@ "ReceiverReadinessCheckStatus", "ReconcileRemoteSkillsRequest", "ReconcileRemoteSkillsResponse", + "RecordBootstrapDeliveryRequest", "RecordRemoteSkillReceiptRequest", "RecordSkillUsageRequest", "RecordTaskOutcomeRequest", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index fca09f4893..8df012c5e6 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -1898,6 +1898,20 @@ class CodeQueryResponse(RootModel[CodeQueryResult | CodeStatus]): root: CodeQueryResult | CodeStatus +class Outcome1(StrEnum): + INJECTED = "injected" + FAILED = "failed" + + +class RecordBootstrapDeliveryRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + receipt_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + outcome: Outcome1 + + class ContextAssemblyFamily(StrEnum): MEMORY = "memory" EXPERIENCE = "experience" @@ -2711,6 +2725,48 @@ class PreparedContextStatus(StrEnum): EMPTY = "empty" +class BootstrapContextSchema(StrEnum): + POWERCONTEXT_BOOTSTRAP_CONTEXT_V1 = "powercontext.bootstrap-context.v1" + + +class BootstrapContextProfile(StrEnum): + POWERCONTEXT_SCOPE_BOOTSTRAP_V1 = "powercontext.scope-bootstrap.v1" + + +class BootstrapContextLifecycle(StrEnum): + STARTUP = "startup" + RESUME = "resume" + CLEAR = "clear" + COMPACT = "compact" + RESTORE = "restore" + FORK = "fork" + + +class BootstrapContextStatus(StrEnum): + READY = "ready" + EMPTY = "empty" + SKIPPED = "skipped" + + +class BootstrapContextItemKind(StrEnum): + ATOMIC_MEMORY = "atomic-memory" + HANDOFF = "handoff" + + +class BootstrapReceiptState(StrEnum): + PENDING = "pending" + INJECTED = "injected" + SKIPPED = "skipped" + FAILED = "failed" + + +class BootstrapSkipReason(StrEnum): + DISABLED = "disabled" + NO_ELIGIBLE_CONTEXT = "no_eligible_context" + ALREADY_DELIVERED = "already_delivered" + PREPARATION_FAILED = "preparation_failed" + + class EntryChangeOperation(StrEnum): ADD = "add" REVISE = "revise" @@ -3698,6 +3754,41 @@ class PreparedContext(BaseModel): content_bytes: Annotated[StrictInt, Field(ge=0)] +class BootstrapContextItem(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: BootstrapContextItemKind + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact: ArtifactReference + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + truncated: StrictBool + + +class BootstrapDeliveryReceipt(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + receipt_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + state: BootstrapReceiptState + + +class BootstrapContext(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + schema_: Annotated[BootstrapContextSchema, Field(alias="schema")] + status: BootstrapContextStatus + reason: Annotated[BootstrapSkipReason | None, Field(...)] + profile: BootstrapContextProfile + content: Annotated[StrictStr | None, Field(...)] + content_bytes: Annotated[StrictInt, Field(ge=0)] + package_digest: Annotated[StrictStr | None, Field(pattern="^sha256:[0-9a-f]{64}$")] + items: Annotated[list[BootstrapContextItem], Field(max_length=7)] + truncated: StrictBool + receipt: BootstrapDeliveryReceipt + + class EntryChange(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3934,6 +4025,30 @@ class MemoryRevisionChanges(BaseModel): changes: list[EntryChange] +class BootstrapContextRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + enabled: StrictBool = False + profile: BootstrapContextProfile = BootstrapContextProfile.POWERCONTEXT_SCOPE_BOOTSTRAP_V1 + lifecycle: BootstrapContextLifecycle + integration: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + event_id: Annotated[ + StrictStr | None, + Field( + description="Optional stable host event identity, persisted only as a digest. Within the same Scope, integration, and lifecycle, retries must keep the profile, max_bytes, and exact Handoff unchanged.", + max_length=512, + min_length=1, + pattern="^[\\x20-\\x7E]+$", + ), + ] = None + max_bytes: Annotated[StrictInt, Field(ge=512, le=8192)] = 4096 + handoff: Annotated[ + ArtifactReference | None, Field(description="Optional exact committed Handoff Revision in the current Scope.") + ] = None + + class ContextAssemblySection(BaseModel): model_config = ConfigDict( extra="forbid", @@ -4473,6 +4588,15 @@ class PrepareContextRequest(BaseModel): Field(description="Opt into current-Scope native code evidence. No index is built during preparation."), ] = False assembly: ContextAssembly | None = None + bootstrap_receipt_id: Annotated[ + StrictStr | None, + Field( + description="Optional exact receipt for the last successfully injected bootstrap package. Only fully delivered Atomic Memory revisions from an injected same-Scope receipt are removed before candidate limiting and sufficiency assessment; Artifacts whose bootstrap bodies were truncated remain eligible.", + max_length=64, + min_length=1, + pattern="^[\\x21-\\x7E]+$", + ), + ] = None class GeneratedCandidateResponse(BaseModel): diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index f095d0849c..c3a9b8d5f6 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -35,6 +35,9 @@ AtomicMemoryRestorationRequest, AtomicMemoryRestorationResponse, AtomicMemoryStateResponse, + BootstrapContext, + BootstrapContextRequest, + BootstrapDeliveryReceipt, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -136,6 +139,7 @@ ReadinessResponse, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, + RecordBootstrapDeliveryRequest, RecordRemoteSkillReceiptRequest, RecordSkillUsageRequest, RecordTaskOutcomeRequest, @@ -1071,6 +1075,56 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), ) +PREPARE_BOOTSTRAP_CONTEXT = Operation[BootstrapContextRequest, BootstrapContext]( + method="POST", + path="/v1/context/bootstrap", + operation_id="prepare_bootstrap_context", + request_type=BootstrapContextRequest, + request_location="body", + path_parameters=(), + response_type=BootstrapContext, + success_status=200, + summary="Prepare bounded context for an Agent lifecycle boundary", + tags=("context",), + scope_mode="current", + responses={ + 200: { + "description": "A bounded package ready for host injection, or a normal empty/skipped result.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +RECORD_BOOTSTRAP_DELIVERY = Operation[RecordBootstrapDeliveryRequest, BootstrapDeliveryReceipt]( + method="POST", + path="/v1/context/bootstrap/receipts", + operation_id="record_bootstrap_delivery", + request_type=RecordBootstrapDeliveryRequest, + request_location="body", + path_parameters=(), + response_type=BootstrapDeliveryReceipt, + success_status=200, + summary="Record a bootstrap delivery outcome", + tags=("context",), + scope_mode="current", + responses={ + 200: {"description": "Current durable delivery state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + CREATE_WORK_CONTRACT = Operation[CreateWorkContractRequest, WorkSourceReceipt]( method="POST", path="/v1/work/contracts/create", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 19170765df..3d0e641414 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1181,6 +1181,92 @@ "x-powercontext-scope-mode": "current", } }, + "/v1/context/bootstrap": { + "post": { + "tags": ["context"], + "summary": "Prepare bounded context for an Agent lifecycle boundary", + "description": "Prepare an opt-in, deterministic " + "bootstrap package without " + "manufacturing a query. Only " + "explicitly curated active Atomic " + "Memory Artifacts and one " + "caller-selected exact committed " + "Handoff are eligible. Returned " + "historical text is untrusted data " + "and grants no authority. A ready " + "receipt remains pending until the " + "host records delivery. Reusing one " + "stable event identity with different " + "package-shaping fields is rejected " + "with 422.", + "operationId": "prepare_bootstrap_context", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/BootstrapContextRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "A bounded package ready for host injection, or a normal empty/skipped result.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BootstrapContext"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/context/bootstrap/receipts": { + "post": { + "tags": ["context"], + "summary": "Record a bootstrap delivery outcome", + "description": "Atomically claim one " + "pending content-free " + "receipt as injected or " + "failed. A second injected " + "claim is rejected with 422, " + "so concurrent lifecycle " + "hooks cannot both inject " + "the same package. A " + "terminal outcome never " + "changes.", + "operationId": "record_bootstrap_delivery", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RecordBootstrapDeliveryRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Current durable delivery state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/BootstrapDeliveryReceipt"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, "/v1/work/contracts/create": { "post": { "tags": ["work"], @@ -7664,6 +7750,59 @@ "type": "object", "required": ["schema", "status", "content", "content_bytes"], }, + "BootstrapContextItem": { + "properties": { + "kind": {"$ref": "#/components/schemas/BootstrapContextItemKind"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "truncated": {"type": "boolean"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "scope_id", "artifact", "content_digest", "truncated"], + }, + "BootstrapDeliveryReceipt": { + "properties": { + "receipt_id": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "state": {"$ref": "#/components/schemas/BootstrapReceiptState"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["receipt_id", "state"], + }, + "BootstrapContext": { + "properties": { + "schema": {"$ref": "#/components/schemas/BootstrapContextSchema"}, + "status": {"$ref": "#/components/schemas/BootstrapContextStatus"}, + "reason": {"allOf": [{"$ref": "#/components/schemas/BootstrapSkipReason"}], "nullable": True}, + "profile": {"$ref": "#/components/schemas/BootstrapContextProfile"}, + "content": {"type": "string", "nullable": True}, + "content_bytes": {"type": "integer", "minimum": 0.0}, + "package_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "nullable": True}, + "items": { + "items": {"$ref": "#/components/schemas/BootstrapContextItem"}, + "type": "array", + "maxItems": 7, + }, + "truncated": {"type": "boolean"}, + "receipt": {"$ref": "#/components/schemas/BootstrapDeliveryReceipt"}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "schema", + "status", + "reason", + "profile", + "content", + "content_bytes", + "package_digest", + "items", + "truncated", + "receipt", + ], + }, "EntryChange": { "properties": { "op": {"$ref": "#/components/schemas/EntryChangeOperation"}, @@ -9084,11 +9223,117 @@ "default": False, }, "assembly": {"$ref": "#/components/schemas/ContextAssembly"}, + "bootstrap_receipt_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "description": "Optional " + "exact " + "receipt " + "for " + "the " + "last " + "successfully " + "injected " + "bootstrap " + "package. " + "Only " + "fully " + "delivered " + "Atomic " + "Memory " + "revisions " + "from " + "an " + "injected " + "same-Scope " + "receipt " + "are " + "removed " + "before " + "candidate " + "limiting " + "and " + "sufficiency " + "assessment; " + "Artifacts " + "whose " + "bootstrap " + "bodies " + "were " + "truncated " + "remain " + "eligible.", + }, }, "additionalProperties": False, "type": "object", "required": ["scope_id", "query"], }, + "BootstrapContextRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "enabled": {"type": "boolean", "default": False}, + "profile": { + "allOf": [{"$ref": "#/components/schemas/BootstrapContextProfile"}], + "default": "powercontext.scope-bootstrap.v1", + }, + "lifecycle": {"$ref": "#/components/schemas/BootstrapContextLifecycle"}, + "integration": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "event_id": { + "type": "string", + "maxLength": 512, + "minLength": 1, + "pattern": "^[\\x20-\\x7E]+$", + "description": "Optional " + "stable " + "host " + "event " + "identity, " + "persisted " + "only " + "as " + "a " + "digest. " + "Within " + "the " + "same " + "Scope, " + "integration, " + "and " + "lifecycle, " + "retries " + "must " + "keep " + "the " + "profile, " + "max_bytes, " + "and " + "exact " + "Handoff " + "unchanged.", + }, + "max_bytes": {"type": "integer", "maximum": 8192.0, "minimum": 512.0, "default": 4096}, + "handoff": { + "$ref": "#/components/schemas/ArtifactReference", + "description": "Optional exact committed Handoff Revision in the current Scope.", + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "lifecycle", "integration"], + }, + "RecordBootstrapDeliveryRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "receipt_id": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "outcome": {"type": "string", "enum": ["injected", "failed"]}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "receipt_id", "outcome"], + }, "ContextAssemblySection": { "properties": { "family": {"$ref": "#/components/schemas/ContextAssemblyFamily"}, @@ -10593,6 +10838,19 @@ "CandidateStatus": {"type": "string", "enum": ["pending", "approved", "rejected"]}, "PreparedContextSchema": {"type": "string", "enum": ["powercontext.prepared-context.v1"]}, "PreparedContextStatus": {"type": "string", "enum": ["ready", "empty"]}, + "BootstrapContextSchema": {"type": "string", "enum": ["powercontext.bootstrap-context.v1"]}, + "BootstrapContextProfile": {"type": "string", "enum": ["powercontext.scope-bootstrap.v1"]}, + "BootstrapContextLifecycle": { + "type": "string", + "enum": ["startup", "resume", "clear", "compact", "restore", "fork"], + }, + "BootstrapContextStatus": {"type": "string", "enum": ["ready", "empty", "skipped"]}, + "BootstrapContextItemKind": {"type": "string", "enum": ["atomic-memory", "handoff"]}, + "BootstrapReceiptState": {"type": "string", "enum": ["pending", "injected", "skipped", "failed"]}, + "BootstrapSkipReason": { + "type": "string", + "enum": ["disabled", "no_eligible_context", "already_delivered", "preparation_failed"], + }, "EntryChangeOperation": { "type": "string", "enum": ["add", "revise", "deactivate", "reactivate", "compact"], diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 7bb45d97be..e30f6ec999 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -240,6 +240,9 @@ from powercontext.builtin.runtime import ( ApproveArtifactCandidateRequest as RuntimeApproveArtifactCandidateRequest, ) +from powercontext.builtin.runtime import BootstrapContext as RuntimeBootstrapContext +from powercontext.builtin.runtime import BootstrapContextRequest as RuntimeBootstrapContextRequest +from powercontext.builtin.runtime import BootstrapDeliveryReceipt as RuntimeBootstrapDeliveryReceipt from powercontext.builtin.runtime import ( CommitConnectorCheckpoint as RuntimeCommitConnectorCheckpoint, ) @@ -277,6 +280,7 @@ ProposeExperienceRequest as RuntimeProposeExperienceRequest, ) from powercontext.builtin.runtime import ProposeSkillRequest as RuntimeProposeSkillRequest +from powercontext.builtin.runtime import RecordBootstrapDeliveryRequest as RuntimeRecordBootstrapDeliveryRequest from powercontext.builtin.runtime import ( RejectArtifactCandidateRequest as RuntimeRejectArtifactCandidateRequest, ) @@ -402,6 +406,9 @@ ArtifactRevision, ArtifactRevisionPage, BaseArtifactFamily, + BootstrapContext, + BootstrapContextRequest, + BootstrapDeliveryReceipt, CandidatePermissions, Capabilities, CaptureContentSourceRequest, @@ -503,6 +510,7 @@ ReadinessStatus, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, + RecordBootstrapDeliveryRequest, RecordRemoteSkillReceiptRequest, RecordSkillUsageRequest, RecordTaskOutcomeRequest, @@ -714,6 +722,7 @@ LIST_SCOPES, LIST_SOURCES, OPENAPI_VERSION, + PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, PREPARE_HANDOFF, PREPARE_HANDOFF_HINT, @@ -726,6 +735,7 @@ QUERY_ARTIFACT_TAGS, QUERY_CODE, RECONCILE_REMOTE_SKILLS, + RECORD_BOOTSTRAP_DELIVERY, RECORD_REMOTE_SKILL_RECEIPT, RECORD_SKILL_USAGE, RECORD_TASK_OUTCOME, @@ -936,6 +946,26 @@ class _ContextApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedContextApplication: ... +class _ScopedBootstrapApplication(Protocol): + async def prepare( + self, + request: RuntimeBootstrapContextRequest, + /, + *, + authorize_scopes: Callable[[tuple[str, ...]], Awaitable[None]] | None = None, + ) -> RuntimeBootstrapContext: ... + + async def record_delivery( + self, + request: RuntimeRecordBootstrapDeliveryRequest, + /, + ) -> RuntimeBootstrapDeliveryReceipt: ... + + +class _BootstrapApplication(Protocol): + def for_scope(self, scope_id: str, /) -> _ScopedBootstrapApplication: ... + + class _ScopedExperienceApplication(Protocol): async def propose(self, request: RuntimeProposeExperienceRequest, /) -> ExperienceCandidate: ... @@ -1220,6 +1250,7 @@ class ServerApplication(Protocol): ingestion: _RemoteIngestionApplication code: CodeApplication context: _ContextApplication + bootstrap: _BootstrapApplication experience: _ExperienceApplication external_skills: _ExternalSkillApplication handoff: _HandoffApplication @@ -1419,6 +1450,8 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, FLUSH_MEMORY, flush_memory) _add_route(app, REMEMBER_MEMORY, remember_memory) _add_route(app, SEARCH_MEMORY, search_memory) + _add_route(app, PREPARE_BOOTSTRAP_CONTEXT, prepare_bootstrap_context) + _add_route(app, RECORD_BOOTSTRAP_DELIVERY, record_bootstrap_delivery) _add_route(app, PREPARE_CONTEXT, prepare_context) _add_route(app, QUERY_CODE, query_code) _add_route(app, CREATE_WORK_CONTRACT, create_work_contract) @@ -2959,6 +2992,44 @@ async def authorize_scopes(scope_ids: tuple[str, ...]) -> None: return mapping.prepared_context_response(result) +async def prepare_bootstrap_context( + request: BootstrapContextRequest, + application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, +) -> BootstrapContext: + prepared_request = mapping.bootstrap_context_request(request) + scoped = application.bootstrap.for_scope(request.scope_id) + access = access_control_for_mode( + http_request.app.state.access_control, + mode=http_request.app.state.access_mode, + ) + if access is None: + result = await scoped.prepare(prepared_request) + else: + + async def authorize_scopes(scope_ids: tuple[str, ...]) -> None: + await access.require_all( + _require_principal(), + tuple((AccessAction.SCOPE_READ, ResourceRef.scope(scope_id)) for scope_id in scope_ids), + context=_access_audit_context(PREPARE_BOOTSTRAP_CONTEXT.operation_id), + ) + for scope_id in scope_ids[1:]: + await require_scope_content_ready(http_request, scope_id) + + result = await scoped.prepare(prepared_request, authorize_scopes=authorize_scopes) + return mapping.bootstrap_context_response(result) + + +async def record_bootstrap_delivery( + request: RecordBootstrapDeliveryRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> BootstrapDeliveryReceipt: + result = await application.bootstrap.for_scope(request.scope_id).record_delivery( + mapping.record_bootstrap_delivery_request(request) + ) + return mapping.bootstrap_delivery_response(result) + + async def create_work_contract( request: CreateWorkContractRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -4421,6 +4492,7 @@ def _add_route( "get_memory_capacity", "list_memory_changes", "prepare_context", + "prepare_bootstrap_context", "list_managed_skills", "list_artifact_candidates", "get_artifact_candidate", diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 4a709c02cd..4a8b7234b2 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -84,6 +84,9 @@ from powercontext.builtin.runtime import ( ApproveArtifactCandidateRequest as RuntimeApproveArtifactCandidateRequest, ) +from powercontext.builtin.runtime import BootstrapContext as RuntimeBootstrapContext +from powercontext.builtin.runtime import BootstrapContextRequest as RuntimeBootstrapContextRequest +from powercontext.builtin.runtime import BootstrapDeliveryReceipt as RuntimeBootstrapDeliveryReceipt from powercontext.builtin.runtime import ( CommitConnectorCheckpoint as RuntimeCommitConnectorCheckpoint, ) @@ -115,6 +118,7 @@ ProposeExperienceRequest as RuntimeProposeExperienceRequest, ) from powercontext.builtin.runtime import ProposeSkillRequest as RuntimeProposeSkillRequest +from powercontext.builtin.runtime import RecordBootstrapDeliveryRequest as RuntimeRecordBootstrapDeliveryRequest from powercontext.builtin.runtime import ( RejectArtifactCandidateRequest as RuntimeRejectArtifactCandidateRequest, ) @@ -161,6 +165,7 @@ ArtifactCandidate, ArtifactCandidatePage, ArtifactReference, + BootstrapDeliveryReceipt, CandidateFamily, CandidateStatus, CaptureContentSourceRequest, @@ -245,6 +250,8 @@ WorkSourceKind, WorkSourceReceipt, ) +from powercontext.http import BootstrapContext as TransportBootstrapContext +from powercontext.http import BootstrapContextRequest as TransportBootstrapContextRequest from powercontext.http import ConnectorBinding as HttpConnectorBinding from powercontext.http import ( FailureRecord as TransportFailureRecord, @@ -296,6 +303,7 @@ from powercontext.http import ( PreparedHandoff as TransportPreparedHandoff, ) +from powercontext.http import RecordBootstrapDeliveryRequest as TransportRecordBootstrapDeliveryRequest from powercontext.http import RepairSurface as TransportRepairSurface from powercontext.server.atomic_memory import record_response from powercontext.sources import ( @@ -651,6 +659,18 @@ def prepare_context_request(value: TransportPrepareContextRequest) -> PrepareCon return PrepareContextRequest.model_validate_json(value.model_dump_json(exclude={"scope_id"}, exclude_unset=True)) +def bootstrap_context_request(value: TransportBootstrapContextRequest) -> RuntimeBootstrapContextRequest: + return RuntimeBootstrapContextRequest.model_validate_json( + value.model_dump_json(exclude={"scope_id"}, exclude_unset=True) + ) + + +def record_bootstrap_delivery_request( + value: TransportRecordBootstrapDeliveryRequest, +) -> RuntimeRecordBootstrapDeliveryRequest: + return RuntimeRecordBootstrapDeliveryRequest.model_validate_json(value.model_dump_json(exclude={"scope_id"})) + + def activate_handoff_request(value: ActivateHandoffRequest) -> ActivateHandoff: return ActivateHandoff( boundary_source=runtime_source_reference(value.boundary_source), @@ -811,6 +831,14 @@ def prepared_context_response(value: PreparedContext) -> TransportPreparedContex }) +def bootstrap_context_response(value: RuntimeBootstrapContext) -> TransportBootstrapContext: + return TransportBootstrapContext.model_validate_json(value.model_dump_json(by_alias=True)) + + +def bootstrap_delivery_response(value: RuntimeBootstrapDeliveryReceipt) -> BootstrapDeliveryReceipt: + return BootstrapDeliveryReceipt.model_validate_json(value.model_dump_json()) + + def entries_response(value: AtomicMemoryPage) -> ListMemoryEntriesResponse: return ListMemoryEntriesResponse( entries=[record_response(item) for item in value.items], next_cursor=value.next_cursor diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index ae0b90a5b4..606f3bbf79 100644 --- a/tests/builtin/persistence/test_mysql_schema.py +++ b/tests/builtin/persistence/test_mysql_schema.py @@ -49,6 +49,8 @@ def _column_budget(column) -> int: return column.type.length if isinstance(column.type, Integer): return 4 + if isinstance(column.type, DateTime): + return 8 if isinstance(column.type, Date): return 3 if isinstance(column.type, DateTime): diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index 350b9e20cb..c39f5e2ed6 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -21,7 +21,7 @@ import pytest from pydantic import JsonValue, ValidationError -from sqlalchemy import event, func, select +from sqlalchemy import event, func, select, update from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactRef @@ -196,6 +196,74 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_tag_query_filters_inactive_heads_before_bounded_materialization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def scenario() -> None: + async with _atomic_record_services() as (contexts, records, _, _): + inactive_ids: list[str] = [] + active_ids: list[str] = [] + for index in range(73): + created = await records.create_artifact( + "scope", + "atomic-memory", + ArtifactWrite(content={"kind": "fact", "text": f"Bounded memory {index}."}), + ) + target = ArtifactTagTarget(family="atomic-memory", artifact_id=created.artifact_id) + empty = await records.get_tags("scope", target) + await records.replace_tags("scope", target, ("bootstrap-context",), expected_etag=empty.etag) + (inactive_ids if index < 40 else active_ids).append(created.artifact_id) + + async with contexts.database.transaction() as connection: + await connection.execute( + update(ARTIFACT_HEADS_TABLE) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == "scope", + ARTIFACT_HEADS_TABLE.c.family == "atomic-memory", + ARTIFACT_HEADS_TABLE.c.artifact_id.in_(inactive_ids), + ) + .values(lifecycle_state="deprecated") + ) + + loaded: list[ArtifactRef] = [] + original_get = ArtifactRepository.get + + async def track_get(repository, connection, scope_id, ref, /, *, for_update=False): + loaded.append(ref) + return await original_get( + repository, + connection, + scope_id, + ref, + for_update=for_update, + ) + + monkeypatch.setattr(ArtifactRepository, "get", track_get) + first = await records.query_tags( + "scope", + TagQuery(tags=("bootstrap-context",), limit=32), + caller="bootstrap-context", + ) + + assert len(first.items) == 32 + assert first.next_cursor is not None + assert len(loaded) == 32 + assert {ref.artifact_id for ref in loaded}.isdisjoint(inactive_ids) + + loaded.clear() + second = await records.query_tags( + "scope", + TagQuery(tags=("bootstrap-context",), limit=32, cursor=first.next_cursor), + caller="bootstrap-context", + ) + assert len(second.items) == 1 + assert second.next_cursor is None + assert len(loaded) == 1 + assert {item.reference.artifact_id for item in (*first.items, *second.items)} == set(active_ids) + + asyncio.run(scenario()) + + def test_in_memory_sqlite_supports_concurrent_tag_reads() -> None: async def scenario() -> None: async with _atomic_record_services() as (_, records, _, _): diff --git a/tests/builtin/runtime/test_bootstrap_context.py b/tests/builtin/runtime/test_bootstrap_context.py new file mode 100644 index 0000000000..1fd8489894 --- /dev/null +++ b/tests/builtin/runtime/test_bootstrap_context.py @@ -0,0 +1,36 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.runtime.bootstrap_context import BootstrapCandidate, build_bootstrap_context +from powercontext.builtin.runtime.models import BootstrapContextItem + + +def test_bootstrap_item_body_limit_includes_the_truncation_marker() -> None: + item = BootstrapContextItem( + kind="atomic-memory", + scope_id="scope:test", + artifact=ArtifactRef(family="atomic-memory", artifact_id="memory", revision=1), + content_digest="sha256:" + ("0" * 64), + ) + + build = build_bootstrap_context((BootstrapCandidate(item=item, content="x" * 2001),), max_bytes=8192) + + assert build.content is not None + body = next(line.removeprefix("> ") for line in build.content.splitlines() if line.startswith("> ")) + assert body == ("x" * 1997) + "…" + assert len(body.encode("utf-8")) == 2000 + assert build.items[0].truncated is True diff --git a/tests/claude_code_plugin/conftest.py b/tests/claude_code_plugin/conftest.py index 7a6801d927..b3bea7bbfa 100644 --- a/tests/claude_code_plugin/conftest.py +++ b/tests/claude_code_plugin/conftest.py @@ -26,8 +26,10 @@ CLAUDE_CODE_ROOT = REPOSITORY_ROOT / "integrations" / "claude-code" PLUGIN_ROOT = CLAUDE_CODE_ROOT / "plugins" / "powercontext" _PLUGIN_MODULE_NAMES = ( + "bootstrap_state", "claude_code_settings", "hooks", + "hooks.bootstrap_context", "hooks.prepared_context", "scripts", "scripts.workspace_scope", @@ -78,6 +80,15 @@ def hook_module(plugin_imports: None) -> ModuleType: @pytest.fixture(autouse=True) def isolated_diagnostic_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("POWERCONTEXT_DIAGNOSTIC_STATE_FILE", str(tmp_path / "claude-code-diagnostics.json")) + monkeypatch.setenv("POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR", str(tmp_path / "plugin-data")) + + +@pytest.fixture +def session_start_module(plugin_imports: None) -> ModuleType: + return _load_module( + "powercontext_claude_code_session_start", + PLUGIN_ROOT / "hooks" / "session_start.py", + ) @pytest.fixture diff --git a/tests/claude_code_plugin/test_bootstrap.py b/tests/claude_code_plugin/test_bootstrap.py new file mode 100644 index 0000000000..06ab515234 --- /dev/null +++ b/tests/claude_code_plugin/test_bootstrap.py @@ -0,0 +1,533 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import io +import json +import os +import sys +from hashlib import sha256 +from pathlib import Path +from threading import Event, Thread +from types import ModuleType +from typing import cast + +import pytest + + +def _ready(content: str = "curated bootstrap context") -> dict[str, object]: + return { + "schema": "powercontext.bootstrap-context.v1", + "status": "ready", + "reason": None, + "profile": "powercontext.scope-bootstrap.v1", + "content": content, + "content_bytes": len(content.encode()), + "package_digest": "sha256:" + sha256(content.encode()).hexdigest(), + "items": [ + { + "kind": "atomic-memory", + "scope_id": "scope:test", + "artifact": {"family": "atomic-memory", "artifact_id": "memory", "revision": 3}, + "content_digest": "sha256:" + "a" * 64, + "truncated": False, + } + ], + "truncated": False, + "receipt": {"receipt_id": "bcr_test", "state": "pending"}, + } + + +def _skipped(reason: str = "disabled") -> dict[str, object]: + return { + "schema": "powercontext.bootstrap-context.v1", + "status": "skipped", + "reason": reason, + "profile": "powercontext.scope-bootstrap.v1", + "content": None, + "content_bytes": 0, + "package_digest": None, + "items": [], + "truncated": False, + "receipt": {"receipt_id": "bcr_skipped", "state": "skipped"}, + } + + +def _empty() -> dict[str, object]: + value = _skipped("no_eligible_context") + value["status"] = "empty" + return value + + +def _already_delivered() -> dict[str, object]: + value = _skipped("already_delivered") + value["receipt"] = {"receipt_id": "bcr_test", "state": "injected"} + return value + + +def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( + session_start_module: ModuleType, + hook_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + requests: list[tuple[str, dict[str, object]]] = [] + + def post(path, payload, **_kwargs): + requests.append((path, payload)) + if path.endswith("/receipts"): + return {"receipt_id": "bcr_test", "state": "injected"} + return _ready() + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "SessionStart", + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + settings = session_start_module.ClaudeCodePluginSettings(bootstrap_context=True) + + assert session_start_module.main(settings) == 0 + assert json.loads(output.getvalue()) == { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "curated bootstrap context", + } + } + assert requests[0] == ( + "/v1/context/bootstrap", + { + "scope_id": "scope:test", + "enabled": True, + "profile": "powercontext.scope-bootstrap.v1", + "lifecycle": "startup", + "integration": "claude-code", + "max_bytes": 4096, + "event_id": "event-1", + }, + ) + assert requests[1] == ( + "/v1/context/bootstrap/receipts", + {"scope_id": "scope:test", "receipt_id": "bcr_test", "outcome": "injected"}, + ) + + state_files = list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + assert len(state_files) == 1 + state = state_files[0].read_text() + assert "curated bootstrap context" not in state + assert "event-1" not in state + assert "session-1" not in state + + prepared_receipts: list[str | None] = [] + + def prepare(_query, _scope, *, settings, deadline, bootstrap_receipt_id=None): + prepared_receipts.append(bootstrap_receipt_id) + return {"schema": "powercontext.prepared-context.v1", "status": "empty", "content": None, "content_bytes": 0} + + monkeypatch.setattr(hook_module, "_prepare_context", prepare) + monkeypatch.setattr(hook_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "cwd": "/workspace", + "prompt": "What applies?", + }) + ), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert hook_module.main(hook_module.ClaudeCodePluginSettings(capture_prompts=False)) == 0 + assert prepared_receipts == ["bcr_test"] + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "cwd": "/workspace", + "prompt": "What applies next?", + }) + ), + ) + assert hook_module.main(hook_module.ClaudeCodePluginSettings(capture_prompts=False)) == 0 + assert prepared_receipts == ["bcr_test", None] + + +def test_repeated_injected_event_preserves_receipt_for_first_query( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + session_start_module.save_receipt("session-1", "scope:test", "bcr_test") + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr(session_start_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + state_files = list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + assert len(state_files) == 1 + assert json.loads(state_files[0].read_text())["receipt_id"] == "bcr_test" + + +def test_terminal_retry_preserves_a_concurrently_saved_winner_receipt( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bootstrap_state = sys.modules[session_start_module.load_receipt_state.__module__] + original_read = bootstrap_state._read_receipt_state + snapshot_read = Event() + winner_saved = Event() + winner_started = False + winner: Thread | None = None + + def synchronized_read(path): + state = original_read(path) + snapshot_read.set() + if winner_started: + assert winner_saved.wait(2) + return state + + def save_winner() -> None: + if snapshot_read.wait(2): + session_start_module.save_receipt("session-1", "scope:test", "bcr_test") + winner_saved.set() + + def post(*_args, **_kwargs): + nonlocal winner, winner_started + winner_started = True + winner = Thread(target=save_winner) + winner.start() + return _already_delivered() + + monkeypatch.setattr(bootstrap_state, "_read_receipt_state", synchronized_read) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert winner is not None + winner.join(timeout=2) + assert not winner.is_alive() + assert session_start_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") + + +def test_invalid_settings_clear_a_stale_receipt( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + session_start_module.save_receipt("session-1", "scope:test", "bcr_previous") + + def invalid_settings(): + raise ValueError + + monkeypatch.setattr( + session_start_module.ClaudeCodePluginSettings, + "from_environment", + staticmethod(invalid_settings), + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "compact", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + assert session_start_module.main() == 0 + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + +@pytest.mark.parametrize("invalid_field", ["receipt", "artifact"]) +def test_bootstrap_response_rejects_identifiers_containing_spaces( + invalid_field: str, + session_start_module: ModuleType, +) -> None: + response = _ready() + if invalid_field == "receipt": + response["receipt"] = {"receipt_id": "bcr test", "state": "pending"} + else: + items = cast(list[dict[str, object]], response["items"]) + artifact = cast(dict[str, object], items[0]["artifact"]) + artifact["artifact_id"] = "memory id" + + with pytest.raises(session_start_module.InvalidBootstrapResponse): + session_start_module.validate_bootstrap_context(response, max_bytes=4096) + + +@pytest.mark.parametrize("lifecycle", ["startup", "resume", "clear", "compact", "fork"]) +def test_session_start_preserves_distinct_lifecycle_sources( + lifecycle: str, + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[dict[str, object]] = [] + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + session_start_module, + "_post_json", + lambda _path, payload, **_kwargs: requests.append(payload) or _skipped(), + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": lifecycle, "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings()) == 0 + assert output.getvalue() == "" + assert requests[0]["lifecycle"] == lifecycle + assert requests[0]["enabled"] is False + + +def test_session_start_fails_open_and_does_not_inject_when_delivery_cannot_be_recorded( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + + def post(path, _payload, **_kwargs): + nonlocal calls + calls += 1 + if path == "/v1/context/bootstrap": + return _ready() + raise OSError + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "resume", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + assert calls == 3 + assert json.loads(errors.getvalue())["outcome"] == "delivery_failed" + + +def test_concurrent_delivery_loser_preserves_the_winner_receipt( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_start_module.save_receipt("session-1", "scope:test", "bcr_previous") + + def post(path, payload, **_kwargs): + if path == "/v1/context/bootstrap": + return _ready() + if payload["outcome"] == "injected": + session_start_module.save_receipt("session-1", "scope:test", "bcr_test") + raise OSError + return {"receipt_id": "bcr_test", "state": "injected"} + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert session_start_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") + + +def test_session_start_output_failure_clears_the_deduplication_receipt( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + def post(path, _payload, **_kwargs): + return {"receipt_id": "bcr_test", "state": "injected"} if path.endswith("/receipts") else _ready() + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + read_descriptor, write_descriptor = os.pipe() + os.close(read_descriptor) + output = os.fdopen(write_descriptor, "w") + monkeypatch.setattr(sys, "stdout", output) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + output.close() + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + monkeypatch.setattr(session_start_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + +def test_session_start_rejects_malformed_bootstrap_without_blocking( + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(session_start_module, "_post_json", lambda *_args, **_kwargs: {"status": "ready"}) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + assert json.loads(errors.getvalue())["outcome"] == "invalid_response" + + +@pytest.mark.parametrize("result", [_empty(), TimeoutError()]) +def test_session_start_empty_or_timeout_fails_open( + result: dict[str, object] | TimeoutError, + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + def post(*_args, **_kwargs): + if isinstance(result, BaseException): + raise result + return result + + monkeypatch.setattr(session_start_module, "_post_json", post) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + session_start_module.save_receipt("session-1", "scope:test", "bcr_previous") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + diagnostic = json.loads(errors.getvalue()) + assert diagnostic["outcome"] == ("server_unavailable" if isinstance(result, BaseException) else "empty") + assert diagnostic["profile"] == "powercontext.scope-bootstrap.v1" + assert "content" not in diagnostic + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + +@pytest.mark.parametrize( + ("http_status", "outcome"), + [ + (401, "authentication_failed"), + (403, "authorization_denied"), + (404, "version_mismatch"), + (503, "server_unavailable"), + (422, "invalid_response"), + ], +) +def test_session_start_classifies_http_failures_without_blocking( + http_status: int, + outcome: str, + session_start_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Response: + status = http_status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + monkeypatch.setattr(session_start_module, "open_bounded", lambda *_args, **_kwargs: Response()) + monkeypatch.setattr(session_start_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + errors = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + monkeypatch.setattr(sys, "stderr", errors) + + assert session_start_module.main(session_start_module.ClaudeCodePluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + diagnostic = json.loads(errors.getvalue()) + assert diagnostic["outcome"] == outcome + assert diagnostic["http_status"] == http_status + assert "content" not in diagnostic diff --git a/tests/claude_code_plugin/test_contract.py b/tests/claude_code_plugin/test_contract.py index e39c8dea46..46ac11abf1 100644 --- a/tests/claude_code_plugin/test_contract.py +++ b/tests/claude_code_plugin/test_contract.py @@ -37,7 +37,7 @@ def test_repository_exposes_a_claude_marketplace() -> None: "name": "powercontext", "source": "./integrations/claude-code/plugins/powercontext", "description": "Restore project memory and transfer current work from Claude Code", - "version": "0.1.2", + "version": "0.1.3", "category": "Productivity", } ] @@ -47,7 +47,7 @@ def test_plugin_uses_standard_component_discovery() -> None: manifest = json.loads((PLUGIN_ROOT / ".claude-plugin" / "plugin.json").read_text()) assert manifest["name"] == "powercontext" - assert manifest["version"] == "0.1.2" + assert manifest["version"] == "0.1.3" assert "hooks" not in manifest assert "mcpServers" not in manifest assert (PLUGIN_ROOT / "hooks" / "hooks.json").is_file() @@ -58,7 +58,12 @@ def test_plugin_uses_standard_component_discovery() -> None: def test_hook_uses_exec_form_and_does_not_capture_stop() -> None: configuration = json.loads((PLUGIN_ROOT / "hooks" / "hooks.json").read_text()) - assert set(configuration["hooks"]) == {"UserPromptSubmit"} + assert set(configuration["hooks"]) == {"SessionStart", "UserPromptSubmit"} + session_start = configuration["hooks"]["SessionStart"][0] + assert session_start["matcher"] == "startup|resume|clear|compact|fork" + session_hook = session_start["hooks"][0] + assert session_hook["command"] == "python3" + assert session_hook["args"] == ["${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py"] hook = configuration["hooks"]["UserPromptSubmit"][0]["hooks"][0] assert hook["command"] == "python3" assert hook["args"] == ["${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.py"] diff --git a/tests/codex_plugin/conftest.py b/tests/codex_plugin/conftest.py index b270bb531f..4f10acb80b 100644 --- a/tests/codex_plugin/conftest.py +++ b/tests/codex_plugin/conftest.py @@ -47,6 +47,7 @@ def recall_module() -> ModuleType: def isolated_diagnostic_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) monkeypatch.setenv("POWERCONTEXT_DIAGNOSTIC_STATE_FILE", str(tmp_path / "codex-diagnostics.json")) + monkeypatch.setenv("POWERCONTEXT_BOOTSTRAP_STATE_DIR", str(tmp_path / "plugin-data")) monkeypatch.delenv("POWERCONTEXT_CODEX_AUTHORIZATION", raising=False) monkeypatch.delenv("POWERCONTEXT_CODEX_SCOPE_ID", raising=False) monkeypatch.delenv("POWERCONTEXT_CLIENT_API_TOKEN", raising=False) diff --git a/tests/codex_plugin/test_bootstrap.py b/tests/codex_plugin/test_bootstrap.py new file mode 100644 index 0000000000..d148cc8322 --- /dev/null +++ b/tests/codex_plugin/test_bootstrap.py @@ -0,0 +1,558 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import io +import json +import os +import sys +from hashlib import sha256 +from pathlib import Path +from threading import Event, Thread +from types import ModuleType +from typing import cast + +import pytest + + +def _ready(content: str = "curated bootstrap context") -> dict[str, object]: + return { + "schema": "powercontext.bootstrap-context.v1", + "status": "ready", + "reason": None, + "profile": "powercontext.scope-bootstrap.v1", + "content": content, + "content_bytes": len(content.encode()), + "package_digest": "sha256:" + sha256(content.encode()).hexdigest(), + "items": [ + { + "kind": "atomic-memory", + "scope_id": "scope:test", + "artifact": {"family": "atomic-memory", "artifact_id": "memory", "revision": 3}, + "content_digest": "sha256:" + "a" * 64, + "truncated": False, + } + ], + "truncated": False, + "receipt": {"receipt_id": "bcr_test", "state": "pending"}, + } + + +def _skipped(reason: str = "disabled") -> dict[str, object]: + return { + "schema": "powercontext.bootstrap-context.v1", + "status": "skipped", + "reason": reason, + "profile": "powercontext.scope-bootstrap.v1", + "content": None, + "content_bytes": 0, + "package_digest": None, + "items": [], + "truncated": False, + "receipt": {"receipt_id": "bcr_skipped", "state": "skipped"}, + } + + +def _empty() -> dict[str, object]: + value = _skipped("no_eligible_context") + value["status"] = "empty" + return value + + +def _already_delivered() -> dict[str, object]: + value = _skipped("already_delivered") + value["receipt"] = {"receipt_id": "bcr_test", "state": "injected"} + return value + + +def test_session_start_without_lifecycle_still_persists_session_binding( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolutions: list[tuple[str, str | None, bool]] = [] + requests: list[str] = [] + + def resolve(cwd, *, session_id, persist_session=False, **_kwargs): + resolutions.append((cwd, session_id, persist_session)) + return "scope:test" + + monkeypatch.setattr(session_binding_module, "resolve_scope_id", resolve) + monkeypatch.setattr( + session_binding_module, + "_post_json", + lambda path, *_args, **_kwargs: requests.append(path) or _skipped(), + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_binding_module.main() == 0 + assert resolutions == [("/workspace", "session-1", True)] + assert requests == [] + assert output.getvalue() == "" + + +def test_session_start_marks_delivery_before_injecting_and_first_query_sends_receipt( + session_binding_module: ModuleType, + recall_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + requests: list[tuple[str, dict[str, object]]] = [] + + def post(path, payload, **_kwargs): + requests.append((path, payload)) + if path.endswith("/receipts"): + return {"receipt_id": "bcr_test", "state": "injected"} + return _ready() + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "SessionStart", + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + settings = session_binding_module.CodexPluginSettings(bootstrap_context=True) + + assert session_binding_module.main(settings) == 0 + assert json.loads(output.getvalue()) == { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "curated bootstrap context", + } + } + assert requests[0][0] == "/v1/context/bootstrap" + assert requests[0][1] == { + "scope_id": "scope:test", + "enabled": True, + "profile": "powercontext.scope-bootstrap.v1", + "lifecycle": "startup", + "integration": "codex", + "max_bytes": 4096, + "event_id": "event-1", + } + assert requests[1] == ( + "/v1/context/bootstrap/receipts", + {"scope_id": "scope:test", "receipt_id": "bcr_test", "outcome": "injected"}, + ) + + state_files = list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + assert len(state_files) == 1 + state = state_files[0].read_text() + assert "curated bootstrap context" not in state + assert "event-1" not in state + assert "session-1" not in state + + prepared_payloads: list[str | None] = [] + + def prepare(_query, _scope, *, settings, deadline, bootstrap_receipt_id=None): + prepared_payloads.append(bootstrap_receipt_id) + return {"schema": "powercontext.prepared-context.v1", "status": "empty", "content": None, "content_bytes": 0} + + monkeypatch.setattr(recall_module, "_prepare_context", prepare) + monkeypatch.setattr(recall_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "cwd": "/workspace", + "prompt": "What applies?", + }) + ), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert recall_module.main(recall_module.CodexPluginSettings(capture_prompts=False)) == 0 + assert prepared_payloads == ["bcr_test"] + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "cwd": "/workspace", + "prompt": "What applies next?", + }) + ), + ) + assert recall_module.main(recall_module.CodexPluginSettings(capture_prompts=False)) == 0 + assert prepared_payloads == ["bcr_test", None] + + +def test_repeated_injected_event_preserves_receipt_for_first_query( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + session_binding_module.save_receipt("session-1", "scope:test", "bcr_test") + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr(session_binding_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + state_files = list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + assert len(state_files) == 1 + assert json.loads(state_files[0].read_text())["receipt_id"] == "bcr_test" + + +def test_terminal_retry_preserves_a_concurrently_saved_winner_receipt( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bootstrap_state = sys.modules[session_binding_module.load_receipt_state.__module__] + original_read = bootstrap_state._read_receipt_state + snapshot_read = Event() + winner_saved = Event() + winner_started = False + winner: Thread | None = None + + def synchronized_read(path): + state = original_read(path) + snapshot_read.set() + if winner_started: + assert winner_saved.wait(2) + return state + + def save_winner() -> None: + if snapshot_read.wait(2): + session_binding_module.save_receipt("session-1", "scope:test", "bcr_test") + winner_saved.set() + + def post(*_args, **_kwargs): + nonlocal winner, winner_started + winner_started = True + winner = Thread(target=save_winner) + winner.start() + return _already_delivered() + + monkeypatch.setattr(bootstrap_state, "_read_receipt_state", synchronized_read) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO( + json.dumps({ + "source": "startup", + "session_id": "session-1", + "cwd": "/workspace", + "event_id": "event-1", + }) + ), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert winner is not None + winner.join(timeout=2) + assert not winner.is_alive() + assert session_binding_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") + + +def test_invalid_settings_clear_a_stale_receipt( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + session_binding_module.save_receipt("session-1", "scope:test", "bcr_previous") + + def invalid_settings(): + raise ValueError + + monkeypatch.setattr(session_binding_module, "CodexPluginSettings", invalid_settings) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "compact", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + assert session_binding_module.main() == 0 + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + +@pytest.mark.parametrize("invalid_field", ["receipt", "artifact"]) +def test_bootstrap_response_rejects_identifiers_containing_spaces( + invalid_field: str, + session_binding_module: ModuleType, +) -> None: + response = _ready() + if invalid_field == "receipt": + response["receipt"] = {"receipt_id": "bcr test", "state": "pending"} + else: + items = cast(list[dict[str, object]], response["items"]) + artifact = cast(dict[str, object], items[0]["artifact"]) + artifact["artifact_id"] = "memory id" + + with pytest.raises(session_binding_module.InvalidBootstrapResponse): + session_binding_module.validate_bootstrap_context(response, max_bytes=4096) + + +@pytest.mark.parametrize("lifecycle", ["startup", "resume", "clear", "compact", "restore", "fork"]) +def test_session_start_preserves_distinct_lifecycle_sources( + lifecycle: str, + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[dict[str, object]] = [] + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + session_binding_module, + "_post_json", + lambda path, payload, **_kwargs: requests.append(payload) or _skipped(), + ) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": lifecycle, "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + + assert session_binding_module.main() == 0 + assert output.getvalue() == "" + assert requests[0]["lifecycle"] == lifecycle + assert requests[0]["enabled"] is False + + +def test_session_start_fails_open_and_does_not_inject_when_delivery_cannot_be_recorded( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + + def post(path, _payload, **_kwargs): + nonlocal calls + calls += 1 + if path == "/v1/context/bootstrap": + return _ready() + raise OSError + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "resume", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + assert calls == 3 + assert json.loads(errors.getvalue())["outcome"] == "delivery_failed" + + +def test_concurrent_delivery_loser_preserves_the_winner_receipt( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_binding_module.save_receipt("session-1", "scope:test", "bcr_previous") + + def post(path, payload, **_kwargs): + if path == "/v1/context/bootstrap": + return _ready() + if payload["outcome"] == "injected": + session_binding_module.save_receipt("session-1", "scope:test", "bcr_test") + raise OSError + return {"receipt_id": "bcr_test", "state": "injected"} + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert session_binding_module.load_receipt_state("session-1") == ("scope:test", "bcr_test") + + +def test_session_start_output_failure_clears_the_deduplication_receipt( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + def post(path, _payload, **_kwargs): + return {"receipt_id": "bcr_test", "state": "injected"} if path.endswith("/receipts") else _ready() + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + read_descriptor, write_descriptor = os.pipe() + os.close(read_descriptor) + output = os.fdopen(write_descriptor, "w") + monkeypatch.setattr(sys, "stdout", output) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + output.close() + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + monkeypatch.setattr(session_binding_module, "_post_json", lambda *_args, **_kwargs: _already_delivered()) + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + +def test_session_start_rejects_malformed_bootstrap_without_blocking( + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(session_binding_module, "_post_json", lambda *_args, **_kwargs: {"status": "ready"}) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + assert json.loads(errors.getvalue())["outcome"] == "invalid_response" + + +@pytest.mark.parametrize("result", [_empty(), TimeoutError()]) +def test_session_start_empty_or_timeout_fails_open( + result: dict[str, object] | TimeoutError, + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + def post(*_args, **_kwargs): + if isinstance(result, BaseException): + raise result + return result + + monkeypatch.setattr(session_binding_module, "_post_json", post) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + session_binding_module.save_receipt("session-1", "scope:test", "bcr_previous") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + errors = io.StringIO() + monkeypatch.setattr(sys, "stderr", errors) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + diagnostic = json.loads(errors.getvalue()) + assert diagnostic["outcome"] == ("server_unavailable" if isinstance(result, BaseException) else "empty") + assert diagnostic["profile"] == "powercontext.scope-bootstrap.v1" + assert "content" not in diagnostic + assert not list((tmp_path / "plugin-data" / "powercontext-bootstrap").glob("*.json")) + + +@pytest.mark.parametrize( + ("http_status", "outcome"), + [ + (401, "authentication_failed"), + (403, "authorization_denied"), + (404, "version_mismatch"), + (503, "server_unavailable"), + (422, "invalid_response"), + ], +) +def test_session_start_classifies_http_failures_without_blocking( + http_status: int, + outcome: str, + session_binding_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Response: + status = http_status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + monkeypatch.setattr(session_binding_module, "open_bounded", lambda *_args, **_kwargs: Response()) + monkeypatch.setattr(session_binding_module, "resolve_scope_id", lambda *_args, **_kwargs: "scope:test") + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps({"source": "startup", "session_id": "session-1", "cwd": "/workspace"})), + ) + output = io.StringIO() + errors = io.StringIO() + monkeypatch.setattr(sys, "stdout", output) + monkeypatch.setattr(sys, "stderr", errors) + + assert session_binding_module.main(session_binding_module.CodexPluginSettings(bootstrap_context=True)) == 0 + assert output.getvalue() == "" + diagnostic = json.loads(errors.getvalue()) + assert diagnostic["outcome"] == outcome + assert diagnostic["http_status"] == http_status + assert "content" not in diagnostic diff --git a/tests/codex_plugin/test_contract.py b/tests/codex_plugin/test_contract.py index 8a91b9dfee..2148905c04 100644 --- a/tests/codex_plugin/test_contract.py +++ b/tests/codex_plugin/test_contract.py @@ -266,7 +266,11 @@ def test_codex_settings_environment_authorization_overrides_stored_authorization def test_codex_hooks_fix_session_and_data_plane_bindings() -> None: configuration = json.loads((PLUGIN_ROOT / "hooks" / "hooks.json").read_text()) - assert "session_binding.py" in configuration["hooks"]["SessionStart"][0]["hooks"][0]["command"] + session_start = configuration["hooks"]["SessionStart"][0] + assert session_start["matcher"] == "startup|resume|clear|compact" + session_hook = session_start["hooks"][0] + assert "session_binding.py" in session_hook["command"] + assert session_hook["additionalContextLimit"] == 8192 pre_tool_use = configuration["hooks"]["PreToolUse"][0] assert pre_tool_use["matcher"] == "mcp__powercontext__.*" assert "bind_tools.py" in pre_tool_use["hooks"][0]["command"] diff --git a/tests/e2e/test_access_control_regressions.py b/tests/e2e/test_access_control_regressions.py index e3f2cc3653..10462c035b 100644 --- a/tests/e2e/test_access_control_regressions.py +++ b/tests/e2e/test_access_control_regressions.py @@ -271,6 +271,211 @@ async def scenario(): asyncio.run(scenario()) +def test_bootstrap_retry_reauthorizes_exact_items_after_context_references_change(tmp_path): + async def scenario(): + async with _server(tmp_path) as (_, client, _): + shared = await _scope(client) + remembered = await client.post( + "/v1/memory/remember", + json={ + "scope_id": shared, + "kind": "constraint", + "text": "PRIVATE bootstrap constraint.", + }, + ) + assert remembered.status_code == 200, remembered.text + memory_id = remembered.json()["records"][0]["artifact"]["artifact_id"] + tags_path = f"/v1/scopes/{shared}/artifacts/atomic-memory/{memory_id}/tags" + tags = await client.get(tags_path) + assert tags.status_code == 200, tags.text + tagged = await client.put( + tags_path, + headers={"If-Match": tags.headers["ETag"]}, + json={"tags": ["bootstrap-context"]}, + ) + assert tagged.status_code == 200, tagged.text + + created = await client.post( + "/v1/scopes", + json={ + "title": "Current bootstrap scope", + "summary": "References reviewed bootstrap context", + "idempotency_key": "bootstrap-current", + "context_references": [shared], + }, + ) + assert created.status_code == 201, created.text + current = created.json()["scope_id"] + await _grant(client, current, "bob", "scope.viewer") + headers = {"Authorization": "Bearer bob"} + payload = { + "scope_id": current, + "enabled": True, + "lifecycle": "resume", + "integration": "access-regression", + "event_id": "stable-bootstrap-event", + } + disabled = await client.post( + "/v1/context/bootstrap", + headers=headers, + json={**payload, "enabled": False, "event_id": "disabled-bootstrap-event"}, + ) + assert disabled.status_code == 200, disabled.text + assert disabled.json()["status"] == "skipped" + assert "PRIVATE bootstrap constraint" not in disabled.text + denied = await client.post("/v1/context/bootstrap", headers=headers, json=payload) + assert denied.status_code == 403 + assert "PRIVATE bootstrap constraint" not in denied.text + + grant = await _grant(client, shared, "bob", "scope.viewer") + pending = await client.post("/v1/context/bootstrap", headers=headers, json=payload) + assert pending.status_code == 200, pending.text + assert pending.json()["status"] == "ready" + assert "PRIVATE bootstrap constraint" in pending.json()["content"] + + updated = await client.put( + f"/v1/scopes/{current}", + json={ + "expected_version": created.json()["version"], + "title": created.json()["title"], + "summary": created.json()["summary"], + "context_references": [], + }, + ) + assert updated.status_code == 200, updated.text + revoked = await client.post( + "/v1/access/bindings/revoke", + json={ + "binding_id": grant["binding_id"], + "expected_version": grant["version"], + "idempotency_key": "revoke-bootstrap-reader", + }, + ) + assert revoked.status_code == 200, revoked.text + retry = await client.post("/v1/context/bootstrap", headers=headers, json=payload) + assert retry.status_code == 403 + assert "PRIVATE bootstrap constraint" not in retry.text + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("reader_has_candidate", [False, True]) +def test_bootstrap_insert_conflict_reauthorizes_the_winning_receipt(tmp_path, monkeypatch, reader_has_candidate): + async def scenario(): + async with _server(tmp_path) as (app, client, _): + private = await _scope(client) + remembered = await client.post( + "/v1/memory/remember", + json={ + "scope_id": private, + "kind": "constraint", + "text": "PRIVATE concurrent bootstrap constraint.", + }, + ) + assert remembered.status_code == 200, remembered.text + memory_id = remembered.json()["records"][0]["artifact"]["artifact_id"] + tags_path = f"/v1/scopes/{private}/artifacts/atomic-memory/{memory_id}/tags" + tags = await client.get(tags_path) + assert tags.status_code == 200, tags.text + tagged = await client.put( + tags_path, + headers={"If-Match": tags.headers["ETag"]}, + json={"tags": ["bootstrap-context"]}, + ) + assert tagged.status_code == 200, tagged.text + + created = await client.post( + "/v1/scopes", + json={ + "title": "Concurrent bootstrap scope", + "summary": "Starts without a Context Reference", + "idempotency_key": "bootstrap-insert-conflict", + }, + ) + assert created.status_code == 201, created.text + current = created.json()["scope_id"] + await _grant(client, current, "bob", "scope.viewer") + if reader_has_candidate: + public = await client.post( + "/v1/memory/remember", + json={ + "scope_id": current, + "kind": "fact", + "text": "Public concurrent bootstrap context.", + }, + ) + assert public.status_code == 200, public.text + public_id = public.json()["records"][0]["artifact"]["artifact_id"] + public_tags_path = f"/v1/scopes/{current}/artifacts/atomic-memory/{public_id}/tags" + public_tags = await client.get(public_tags_path) + assert public_tags.status_code == 200, public_tags.text + public_tagged = await client.put( + public_tags_path, + headers={"If-Match": public_tags.headers["ETag"]}, + json={"tags": ["bootstrap-context"]}, + ) + assert public_tagged.status_code == 200, public_tagged.text + headers = {"Authorization": "Bearer bob"} + payload = { + "scope_id": current, + "enabled": True, + "lifecycle": "resume", + "integration": "access-regression", + "event_id": "concurrent-bootstrap-event", + } + + repository = app.state.application._bootstrap_receipts + original_create = repository.create + reader_waiting = asyncio.Event() + release_reader = asyncio.Event() + first_create = True + + async def pause_first_receipt(receipt): + nonlocal first_create + if first_create: + first_create = False + assert bool(receipt.items) is reader_has_candidate + reader_waiting.set() + await release_reader.wait() + return await original_create(receipt) + + monkeypatch.setattr(repository, "create", pause_first_receipt) + reader_task = asyncio.create_task(client.post("/v1/context/bootstrap", headers=headers, json=payload)) + try: + await asyncio.wait_for(reader_waiting.wait(), timeout=5) + updated = await client.put( + f"/v1/scopes/{current}", + json={ + "expected_version": created.json()["version"], + "title": created.json()["title"], + "summary": created.json()["summary"], + "context_references": [private], + }, + ) + assert updated.status_code == 200, updated.text + winner = await client.post("/v1/context/bootstrap", json=payload) + assert winner.status_code == 200, winner.text + assert winner.json()["status"] == "ready" + assert "PRIVATE concurrent bootstrap constraint" in winner.json()["content"] + finally: + release_reader.set() + + denied = await reader_task + assert denied.status_code == 403, denied.text + assert "PRIVATE concurrent bootstrap constraint" not in denied.text + assert ( + await client.get( + f"/v1/scopes/{private}/artifacts/atomic-memory/{memory_id}", + headers=headers, + ) + ).status_code == 403 + retry = await client.post("/v1/context/bootstrap", headers=headers, json=payload) + assert retry.status_code == 403 + assert "PRIVATE concurrent bootstrap constraint" not in retry.text + + asyncio.run(scenario()) + + async def _grant(client, scope_id, principal, role, resource=None): response = await client.post( "/v1/access/bindings/create", diff --git a/tests/e2e/test_bootstrap_context.py b/tests/e2e/test_bootstrap_context.py new file mode 100644 index 0000000000..e73bd32927 --- /dev/null +++ b/tests/e2e/test_bootstrap_context.py @@ -0,0 +1,780 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""End-to-end acceptance for curated lifecycle bootstrap context.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import httpx +from sqlalchemy import select, update + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.tables import CONTEXT_BOOTSTRAP_RECEIPTS_TABLE +from powercontext.builtin.runtime.application import ScopedBootstrapApplication +from powercontext.builtin.runtime.config import RuntimeConfig +from powercontext.builtin.runtime.recall_sufficiency import RecallSufficiencyGate +from powercontext.client import PowerContextClient +from powercontext.http import ( + BootstrapContextRequest, + ContextReference, + CreateArtifactRequest, + CreateScopeRequest, + CreateSourceRequest, + PrepareContextRequest, + RecordBootstrapDeliveryRequest, + RememberMemoryRequest, + ReplaceArtifactRequest, + ReplaceArtifactTagsRequest, +) +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, MetricsConfig, ServerSettings + + +async def _tag_for_bootstrap( + client: PowerContextClient, + scope_id: str, + artifact_id: str, +) -> None: + tags = await client.get_artifact_tags(scope_id, "atomic-memory", artifact_id) + assert tags is not None + await client.replace_artifact_tags( + scope_id, + "atomic-memory", + artifact_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["bootstrap-context"]}), + expected_etag=tags.etag, + ) + + +async def _replace_atomic_memory( + client: PowerContextClient, + transport: httpx.AsyncClient, + scope_id: str, + artifact_id: str, + *, + kind: str, + text: str, +): + current = await transport.get(f"/v1/scopes/{scope_id}/artifacts/atomic-memory/{artifact_id}") + assert current.status_code == 200, current.text + return await client.replace_artifact( + scope_id, + "atomic-memory", + artifact_id, + ReplaceArtifactRequest.model_validate({"content": {"kind": kind, "text": text}}), + expected_etag=current.headers["ETag"], + ) + + +def test_bootstrap_is_opt_in_exact_bounded_idempotent_and_keeps_partial_memory_recallable(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'bootstrap.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest(title="Bootstrap", summary="Lifecycle context", idempotency_key="bootstrap") + ) + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="decision", + text="BOOTSTRAP-ONLY: regenerate the OpenAPI bindings before contract tests. " + + "中文上下文。" * 60 + + " OMITTED-TAIL: restore the lunar archive after validation.", + ) + ) + assert len(remembered.records) == 1 + entry = remembered.records[0] + await _tag_for_bootstrap(client, scope.scope_id, entry.artifact.artifact_id) + secret = await client.remember_memory( + RememberMemoryRequest(scope_id=scope.scope_id, kind="access-key", text="do-not-inject-secret") + ) + assert len(secret.records) == 1 + await _tag_for_bootstrap(client, scope.scope_id, secret.records[0].artifact.artifact_id) + + disabled = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "lifecycle": "startup", + "integration": "acceptance", + }) + ) + assert disabled.status == "skipped" + assert disabled.reason == "disabled" + assert disabled.receipt.state == "skipped" + database = app.state.application._bootstrap_receipts._database + async with database.transaction() as connection: + assert (await connection.execute(select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE))).all() == [] + + request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "stable-event-1", + "max_bytes": 1024, + }) + prepared = await client.prepare_bootstrap_context(request) + assert prepared.status == "ready" + assert prepared.content is not None + assert "BOOTSTRAP-ONLY" in prepared.content + assert "OMITTED-TAIL" not in prepared.content + assert "do-not-inject-secret" not in prepared.content + assert prepared.content_bytes == len(prepared.content.encode("utf-8")) <= 1024 + assert prepared.truncated is True + assert len(prepared.items) == 1 + assert prepared.items[0].artifact == entry.artifact + assert prepared.items[0].truncated is True + + retried = await client.prepare_bootstrap_context(request) + assert retried.content == prepared.content + assert retried.receipt == prepared.receipt + mismatched_retry = await transport.post( + "/v1/context/bootstrap", + json={ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "stable-event-1", + "max_bytes": 512, + }, + ) + assert mismatched_retry.status_code == 422 + recorded = await client.record_bootstrap_delivery( + RecordBootstrapDeliveryRequest.model_validate({ + "scope_id": scope.scope_id, + "receipt_id": prepared.receipt.receipt_id, + "outcome": "injected", + }) + ) + assert recorded.state == "injected" + duplicate_claim = await transport.post( + "/v1/context/bootstrap/receipts", + json={"scope_id": scope.scope_id, "receipt_id": prepared.receipt.receipt_id, "outcome": "injected"}, + ) + assert duplicate_claim.status_code == 422 + repeated_record = await client.record_bootstrap_delivery( + RecordBootstrapDeliveryRequest.model_validate({ + "scope_id": scope.scope_id, + "receipt_id": prepared.receipt.receipt_id, + "outcome": "failed", + }) + ) + assert repeated_record.state == "injected" + terminal_retry = await client.prepare_bootstrap_context(request) + assert terminal_retry.status == "skipped" + assert terminal_retry.reason == "already_delivered" + assert terminal_retry.receipt.state == "injected" + + ordinary = await client.prepare_context( + PrepareContextRequest(scope_id=scope.scope_id, query="restore lunar archive validation") + ) + assert ordinary.status == "ready" + assert ordinary.content is not None and "OMITTED-TAIL" in ordinary.content + with_receipt = await client.prepare_context( + PrepareContextRequest( + scope_id=scope.scope_id, + query="restore lunar archive validation", + bootstrap_receipt_id=prepared.receipt.receipt_id, + ) + ) + assert with_receipt.status == "ready" + assert with_receipt.content is not None and "OMITTED-TAIL" in with_receipt.content + + revised = await _replace_atomic_memory( + client, + transport, + scope.scope_id, + entry.artifact.artifact_id, + kind="decision", + text="BOOTSTRAP-ONLY: regenerate both Python and JavaScript bindings.", + ) + after_revision = await client.prepare_context( + PrepareContextRequest( + scope_id=scope.scope_id, + query="regenerate Python JavaScript bindings", + bootstrap_receipt_id=prepared.receipt.receipt_id, + ) + ) + assert after_revision.status == "ready" + assert after_revision.content is not None and "both Python and JavaScript" in after_revision.content + + pending_retry_request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + "event_id": "stable-event-2", + "max_bytes": 1024, + }) + pending_before_revision = await client.prepare_bootstrap_context(pending_retry_request) + assert pending_before_revision.status == "ready" + changed_again = await _replace_atomic_memory( + client, + transport, + scope.scope_id, + entry.artifact.artifact_id, + kind="decision", + text="SECOND-REVISION: this must not change a pending bootstrap retry.", + ) + assert changed_again.revision == revised.revision + 1 + stable_retry = await client.prepare_bootstrap_context(pending_retry_request) + assert stable_retry.receipt == pending_before_revision.receipt + assert stable_retry.content == pending_before_revision.content + assert stable_retry.items == pending_before_revision.items + assert stable_retry.content is not None and "SECOND-REVISION" not in stable_retry.content + + disabled_retry = await client.prepare_bootstrap_context( + pending_retry_request.model_copy(update={"enabled": False}) + ) + assert disabled_retry.status == "skipped" + assert disabled_retry.reason == "disabled" + assert disabled_retry.content is None + assert disabled_retry.receipt.state == "skipped" + assert disabled_retry.receipt.receipt_id == pending_before_revision.receipt.receipt_id + reenabled_same_event = await client.prepare_bootstrap_context(pending_retry_request) + assert reenabled_same_event.status == "skipped" + assert reenabled_same_event.reason == "already_delivered" + + async with database.transaction() as connection: + row = ( + ( + await connection.execute( + select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE).where( + CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.receipt_id == prepared.receipt.receipt_id + ) + ) + ) + .mappings() + .one() + ) + serialized = repr(dict(row)) + assert "BOOTSTRAP-ONLY" not in serialized + assert "regenerate OpenAPI" not in serialized + assert "stable-event-1" not in serialized + + asyncio.run(scenario()) + + +def test_bootstrap_deduplicates_complete_atomic_revision_before_recall_sufficiency_gate( + tmp_path, + monkeypatch, +) -> None: + assessed_memory_counts: list[int] = [] + original_assess = RecallSufficiencyGate.assess + + def observe_assess(self, candidates, *args, **kwargs): + assessed_memory_counts.append(sum(candidate.family == "memory" for candidate in candidates)) + return original_assess(self, candidates, *args, **kwargs) + + monkeypatch.setattr(RecallSufficiencyGate, "assess", observe_assess) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'deduplicate-atomic-revision.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + runtime=RuntimeConfig( + recall_gate_enabled=True, + recall_gate_min_top_score=0.0, + recall_gate_min_top_gap=0.0, + recall_gate_min_lexical_overlap=0.0, + ), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest( + title="Atomic revision deduplication", + summary="Ignore unrelated Atomic Memory writes", + idempotency_key="atomic-revision-deduplication", + ) + ) + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="decision", + text="Use the orange moonlight protocol for release validation.", + ) + ) + assert len(remembered.records) == 1 + entry = remembered.records[0] + await _tag_for_bootstrap(client, scope.scope_id, entry.artifact.artifact_id) + request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "atomic-revision-event", + "max_bytes": 4096, + }) + prepared = await client.prepare_bootstrap_context(request) + assert prepared.status == "ready" + assert len(prepared.items) == 1 + assert prepared.items[0].truncated is False + await client.record_bootstrap_delivery( + RecordBootstrapDeliveryRequest.model_validate({ + "scope_id": scope.scope_id, + "receipt_id": prepared.receipt.receipt_id, + "outcome": "injected", + }) + ) + + unrelated = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="fact", + text="The cafeteria closes at six.", + ) + ) + assert len(unrelated.records) == 1 + assert unrelated.records[0].artifact.artifact_id != entry.artifact.artifact_id + + ordinary = await client.prepare_context( + PrepareContextRequest(scope_id=scope.scope_id, query="orange moonlight release protocol") + ) + assert ordinary.status == "ready" + assert ordinary.content is not None and "orange moonlight" in ordinary.content + deduplicated = await client.prepare_context( + PrepareContextRequest( + scope_id=scope.scope_id, + query="orange moonlight release protocol", + bootstrap_receipt_id=prepared.receipt.receipt_id, + ) + ) + assert deduplicated.status == "empty" + assert assessed_memory_counts[-1] == 0 + + asyncio.run(scenario()) + + +def test_bootstrap_deduplication_replenishes_candidates_before_the_limit(tmp_path, monkeypatch) -> None: + assessed_memory_counts: list[int] = [] + original_assess = RecallSufficiencyGate.assess + + def observe_assess(self, candidates, *args, **kwargs): + assessed_memory_counts.append(sum(candidate.family == "memory" for candidate in candidates)) + return original_assess(self, candidates, *args, **kwargs) + + monkeypatch.setattr(RecallSufficiencyGate, "assess", observe_assess) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'deduplicate-before-limit.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + runtime=RuntimeConfig( + recall_gate_enabled=True, + recall_gate_min_top_score=0.0, + recall_gate_min_top_gap=0.0, + recall_gate_min_lexical_overlap=0.0, + ), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest( + title="Candidate replenishment", + summary="Exclude bootstrap revisions before limiting recall", + idempotency_key="candidate-replenishment", + ) + ) + memories = [] + for index in range(22): + marker = f"REPLENISH-{index:02d}" + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="fact", + text=f"Candidate replenishment evidence {marker} remains independently recallable.", + ) + ) + assert len(remembered.records) == 1 + memories.append((marker, remembered.records[0])) + + recall_request = PrepareContextRequest.model_validate({ + "scope_id": scope.scope_id, + "query": "candidate replenishment evidence", + "max_bytes": 32768, + "assembly": {"sections": [{"family": "memory", "limit": 8}]}, + }) + ordinary = await client.prepare_context(recall_request) + assert ordinary.content is not None + assert ordinary.content.count("REPLENISH-") == 8 + assert assessed_memory_counts[-1] == 16 + delivered = [(marker, record) for marker, record in memories if marker in ordinary.content][:6] + assert len(delivered) == 6 + for _marker, record in delivered: + await _tag_for_bootstrap(client, scope.scope_id, record.artifact.artifact_id) + + prepared = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "candidate-replenishment-event", + "max_bytes": 4096, + }) + ) + assert prepared.status == "ready" + assert len(prepared.items) == 6 + assert all(not item.truncated for item in prepared.items) + await client.record_bootstrap_delivery( + RecordBootstrapDeliveryRequest.model_validate({ + "scope_id": scope.scope_id, + "receipt_id": prepared.receipt.receipt_id, + "outcome": "injected", + }) + ) + + deduplicated = await client.prepare_context( + recall_request.model_copy(update={"bootstrap_receipt_id": prepared.receipt.receipt_id}) + ) + assert deduplicated.content is not None + assert deduplicated.content.count("REPLENISH-") == 8 + assert all(marker not in deduplicated.content for marker, _record in delivered) + assert assessed_memory_counts[-1] == 16 + + asyncio.run(scenario()) + + +def test_pending_bootstrap_retry_preserves_package_level_omission(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'package-omission-retry.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest( + title="Package omission retry", + summary="Preserve package-level truncation", + idempotency_key="package-omission-retry", + ) + ) + entries = [] + for marker in ("FIRST-ITEM", "SECOND-ITEM"): + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=scope.scope_id, + kind="decision", + text=f"{marker}: keep this complete body in deterministic order.", + ) + ) + assert len(remembered.records) == 1 + entries.append(remembered.records[0]) + for entry in entries: + await _tag_for_bootstrap(client, scope.scope_id, entry.artifact.artifact_id) + + request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + "event_id": "package-omission-event", + "max_bytes": 900, + }) + prepared = await client.prepare_bootstrap_context(request) + assert prepared.status == "ready" + assert prepared.truncated is True + assert len(prepared.items) == 1 + assert prepared.items[0].truncated is False + + retried = await client.prepare_bootstrap_context(request) + assert retried.status == "ready" + assert retried.receipt == prepared.receipt + assert retried.content == prepared.content + assert retried.items == prepared.items + assert retried.truncated is True + + asyncio.run(scenario()) + + +def test_bootstrap_includes_only_the_explicit_exact_handoff(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'handoff-bootstrap.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest(title="Handoff", summary="Exact bootstrap", idempotency_key="handoff-bootstrap") + ) + evidence = await client.create_source(scope.scope_id, CreateSourceRequest(content="Verified state")) + created = await client.create_artifact( + scope.scope_id, + CreateArtifactRequest.model_validate({ + "family": "handoff", + "content": { + "schema": "powercontext.handoff.v1", + "objective": "Continue exact bootstrap work.", + "state": [ + { + "text": "The bootstrap API is implemented.", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": evidence.source_id}, + } + ], + } + ], + "disposition": "continuable", + "next_action": None, + "omissions": [], + }, + }), + ) + exact = {"family": "handoff", "artifact_id": created.artifact_id, "revision": created.revision} + without_selection = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + }) + ) + assert without_selection.status == "empty" + database = app.state.application._bootstrap_receipts._database + async with database.transaction() as connection: + assert (await connection.execute(select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE))).all() == [] + selected = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + "handoff": exact, + }) + ) + assert selected.status == "ready" + assert selected.content is not None and "Continue exact bootstrap work." in selected.content + assert [(item.kind, item.artifact.revision) for item in selected.items] == [("handoff", 1)] + + asyncio.run(scenario()) + + +def test_bootstrap_receipts_expire_after_the_retry_window(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'receipt-retention.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest(title="Receipt retention", summary="Bound receipt rows", idempotency_key="expiry") + ) + old = await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "lifecycle": "startup", + "integration": "acceptance", + "event_id": "expired-event", + }) + ) + database = app.state.application._bootstrap_receipts._database + async with database.transaction() as connection: + await connection.execute( + update(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE) + .where(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.receipt_id == old.receipt.receipt_id) + .values(created_at=datetime.now(UTC) - timedelta(days=31)) + ) + + await client.prepare_context( + PrepareContextRequest( + scope_id=scope.scope_id, + query="Check whether retained context exists.", + bootstrap_receipt_id=old.receipt.receipt_id, + ) + ) + async with database.transaction() as connection: + assert (await connection.execute(select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE))).all() == [] + + await client.prepare_bootstrap_context( + BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "lifecycle": "resume", + "integration": "acceptance", + "event_id": "current-event", + }) + ) + async with database.transaction() as connection: + receipt_ids = set( + (await connection.execute(select(CONTEXT_BOOTSTRAP_RECEIPTS_TABLE.c.receipt_id))).scalars() + ) + assert old.receipt.receipt_id not in receipt_ids + assert len(receipt_ids) == 1 + + asyncio.run(scenario()) + + +def test_reconstruction_failure_uses_a_concurrent_terminal_receipt_state(tmp_path, monkeypatch) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'reconstruction-race.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope = await client.create_scope( + CreateScopeRequest(title="Receipt race", summary="Concurrent terminal state", idempotency_key="race") + ) + remembered = await client.remember_memory( + RememberMemoryRequest(scope_id=scope.scope_id, kind="decision", text="Keep the blue release flag.") + ) + assert len(remembered.records) == 1 + await _tag_for_bootstrap(client, scope.scope_id, remembered.records[0].artifact.artifact_id) + request = BootstrapContextRequest.model_validate({ + "scope_id": scope.scope_id, + "enabled": True, + "lifecycle": "resume", + "integration": "acceptance", + "event_id": "reconstruction-race", + }) + prepared = await client.prepare_bootstrap_context(request) + assert prepared.status == "ready" + + async def fail_reconstruction(_application, _item): + raise ValueError + + repository = app.state.application._bootstrap_receipts + original_record_delivery = repository.record_delivery + + async def concurrent_delivery(scope_id, receipt_id, outcome): + assert outcome == "failed" + await original_record_delivery(scope_id, receipt_id, "injected") + return await original_record_delivery(scope_id, receipt_id, outcome) + + monkeypatch.setattr(ScopedBootstrapApplication, "_candidate_from_item", fail_reconstruction) + monkeypatch.setattr(repository, "record_delivery", concurrent_delivery) + retried = await client.prepare_bootstrap_context(request) + assert retried.status == "skipped" + assert retried.reason == "already_delivered" + assert retried.receipt.state == "injected" + + asyncio.run(scenario()) + + +def test_bootstrap_considers_only_the_first_eight_direct_context_references(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'bounded-references.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + references = [ + await client.create_scope( + CreateScopeRequest( + title=f"Reference {index}", summary="Bounded scan", idempotency_key=f"ref-{index}" + ) + ) + for index in range(9) + ] + last_reference = max(scope.scope_id for scope in references) + remembered = await client.remember_memory( + RememberMemoryRequest( + scope_id=last_reference, + kind="decision", + text="NINTH-REFERENCE: this is outside the bootstrap scan.", + ) + ) + assert len(remembered.records) == 1 + await _tag_for_bootstrap(client, last_reference, remembered.records[0].artifact.artifact_id) + current = await client.create_scope( + CreateScopeRequest( + title="Current", + summary="Nine direct references", + idempotency_key="current-bounded", + context_references=[ContextReference(root=scope.scope_id) for scope in references], + ) + ) + request = BootstrapContextRequest.model_validate({ + "scope_id": current.scope_id, + "enabled": True, + "lifecycle": "startup", + "integration": "acceptance", + }) + bounded = await client.prepare_bootstrap_context(request) + assert bounded.status == "empty" + assert bounded.reason == "no_eligible_context" + + direct = await client.prepare_bootstrap_context(request.model_copy(update={"scope_id": last_reference})) + assert direct.status == "ready" + assert direct.content is not None and "NINTH-REFERENCE" in direct.content + + asyncio.run(scenario()) diff --git a/tests/e2e/test_bootstrap_host_service_chain.py b/tests/e2e/test_bootstrap_host_service_chain.py new file mode 100644 index 0000000000..e89aa59841 --- /dev/null +++ b/tests/e2e/test_bootstrap_host_service_chain.py @@ -0,0 +1,221 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import socket +import subprocess +import sys +import threading +import time +from pathlib import Path + +import httpx +import pytest +import uvicorn + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.client import PowerContextClient +from powercontext.http import ( + CreateScopeRequest, + RememberMemoryRequest, + ReplaceArtifactTagsRequest, +) +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, MetricsConfig, ServerSettings + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +PLUGIN_ROOTS = { + "codex": PROJECT_ROOT / "integrations" / "codex" / "plugins" / "powercontext", + "claude-code": PROJECT_ROOT / "integrations" / "claude-code" / "plugins" / "powercontext", +} +BOOTSTRAP_TEXT = "HOST-REAL-BOOTSTRAP: use the exact packaged startup context chain." + + +@pytest.mark.parametrize("host", ["codex", "claude-code"]) +def test_packaged_host_hooks_complete_the_live_bootstrap_chain(tmp_path: Path, host: str) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / f'{host}.db'}"), + mcp=McpConfig(enabled=False), + metrics=MetricsConfig(enabled=False), + ) + ) + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + address, port = listener.getsockname() + base_url = f"http://{address}:{port}" + server = uvicorn.Server(uvicorn.Config(app, log_level="critical", lifespan="on")) + thread = threading.Thread(target=server.run, kwargs={"sockets": [listener]}, daemon=True) + thread.start() + try: + _wait_until_started(server, thread) + scope_id = asyncio.run(_seed_bootstrap_memory(base_url, host)) + plugin = _copy_plugin(tmp_path, host, base_url) + environment = _hook_environment(tmp_path, host, base_url, scope_id) + session_id = f"{host}-bootstrap-session" + + started = _run_hook( + plugin, + _session_start_script(host), + environment, + { + "hook_event_name": "SessionStart", + "source": "startup", + "session_id": session_id, + "cwd": str(PROJECT_ROOT), + }, + ) + bootstrap = json.loads(started.stdout)["hookSpecificOutput"] + assert bootstrap["hookEventName"] == "SessionStart" + assert BOOTSTRAP_TEXT in bootstrap["additionalContext"] + + first_prompt = _run_hook( + plugin, + _user_prompt_script(host), + environment, + _user_prompt_payload(host, session_id), + ) + assert first_prompt.stdout == "" + assert '"outcome":"empty"' in first_prompt.stderr + + ordinary_recall = _run_hook( + plugin, + _user_prompt_script(host), + environment, + _user_prompt_payload(host, f"{session_id}-without-bootstrap"), + ) + recalled = json.loads(ordinary_recall.stdout)["hookSpecificOutput"] + assert recalled["hookEventName"] == "UserPromptSubmit" + assert BOOTSTRAP_TEXT in recalled["additionalContext"] + finally: + server.should_exit = True + thread.join(timeout=10) + listener.close() + assert not thread.is_alive() + + +async def _seed_bootstrap_memory(base_url: str, host: str) -> str: + async with ( + httpx.AsyncClient(trust_env=False) as transport, + PowerContextClient(base_url, http_client=transport, trust_transport_security=True) as client, + ): + scope = await client.create_scope( + CreateScopeRequest( + title=f"{host} bootstrap acceptance", + summary="Live packaged hook acceptance for lifecycle bootstrap context.", + idempotency_key=f"{host}-bootstrap-host-service-chain", + ) + ) + remembered = await client.remember_memory( + RememberMemoryRequest(scope_id=scope.scope_id, kind="decision", text=BOOTSTRAP_TEXT) + ) + assert len(remembered.records) == 1 + artifact_id = remembered.records[0].artifact.artifact_id + tags = await client.get_artifact_tags(scope.scope_id, "atomic-memory", artifact_id) + assert tags is not None + await client.replace_artifact_tags( + scope.scope_id, + "atomic-memory", + artifact_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["bootstrap-context"]}), + expected_etag=tags.etag, + ) + return scope.scope_id + + +def _copy_plugin(tmp_path: Path, host: str, base_url: str) -> Path: + plugin = tmp_path / f"{host}-plugin" + shutil.copytree(PLUGIN_ROOTS[host], plugin, ignore=shutil.ignore_patterns("__pycache__", ".venv")) + if host == "codex": + configuration_path = plugin / ".mcp.json" + configuration = json.loads(configuration_path.read_text(encoding="utf-8")) + configuration["mcpServers"]["powercontext"]["url"] = f"{base_url}/mcp" + configuration_path.write_text(json.dumps(configuration), encoding="utf-8") + return plugin + + +def _hook_environment(tmp_path: Path, host: str, base_url: str, scope_id: str) -> dict[str, str]: + environment: dict[str, str] = dict(os.environ) + environment.update({"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"}) + if host == "codex": + environment.pop("POWERCONTEXT_CODEX_AUTHORIZATION", None) + environment.update({ + "POWERCONTEXT_CODEX_SCOPE_ID": scope_id, + "POWERCONTEXT_CODEX_BOOTSTRAP_CONTEXT": "true", + "POWERCONTEXT_CODEX_CAPTURE_PROMPTS": "false", + "POWERCONTEXT_CODEX_HTTP_BUDGET_SECONDS": "10", + "POWERCONTEXT_CODEX_REQUEST_TIMEOUT_SECONDS": "5", + "POWERCONTEXT_BOOTSTRAP_STATE_DIR": str(tmp_path / "codex-state"), + }) + else: + environment.pop("POWERCONTEXT_CLAUDE_AUTHORIZATION", None) + environment.update({ + "POWERCONTEXT_CLAUDE_SERVER_URL": base_url, + "POWERCONTEXT_CLAUDE_SCOPE_ID": scope_id, + "POWERCONTEXT_CLAUDE_BOOTSTRAP_CONTEXT": "true", + "POWERCONTEXT_CLAUDE_CAPTURE_PROMPTS": "false", + "POWERCONTEXT_CLAUDE_HTTP_BUDGET_SECONDS": "10", + "POWERCONTEXT_CLAUDE_REQUEST_TIMEOUT_SECONDS": "5", + "POWERCONTEXT_CLAUDE_BOOTSTRAP_STATE_DIR": str(tmp_path / "claude-state"), + }) + return environment + + +def _session_start_script(host: str) -> str: + return "session_binding.py" if host == "codex" else "session_start.py" + + +def _user_prompt_script(host: str) -> str: + return "recall.py" if host == "codex" else "user_prompt_submit.py" + + +def _user_prompt_payload(host: str, session_id: str) -> dict[str, str]: + payload = { + "hook_event_name": "UserPromptSubmit", + "cwd": str(PROJECT_ROOT), + "prompt": BOOTSTRAP_TEXT, + "session_id": session_id, + } + payload["turn_id" if host == "codex" else "prompt_id"] = "first-prompt" + return payload + + +def _run_hook( + plugin: Path, + script: str, + environment: dict[str, str], + payload: dict[str, str], +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(plugin / "hooks" / script)], + cwd=PROJECT_ROOT, + env=environment, + input=json.dumps(payload), + text=True, + capture_output=True, + check=True, + timeout=15, + ) + + +def _wait_until_started(server: uvicorn.Server, thread: threading.Thread) -> None: + deadline = time.monotonic() + 10 + while thread.is_alive() and not server.started and time.monotonic() < deadline: + time.sleep(0.01) + assert server.started diff --git a/tests/e2e/test_recall_sufficiency_gate.py b/tests/e2e/test_recall_sufficiency_gate.py index badb19d510..5e3ca41b8d 100644 --- a/tests/e2e/test_recall_sufficiency_gate.py +++ b/tests/e2e/test_recall_sufficiency_gate.py @@ -118,6 +118,7 @@ async def counted( admission: Any, reuse: Any, topic_reuse: Any, + excluded_memory: Any, ) -> Any: log.calls.append({"families": set(families), "admission": admission}) if log.empty_from is not None and len(log.calls) >= log.empty_from: @@ -131,6 +132,7 @@ async def counted( admission=admission, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=excluded_memory, ) log.calls[-1]["memory_recoverable"] = result.memory_recoverable log.calls[-1]["memory_texts"] = tuple(hit.text for group in result.memory for hit in group.hits) @@ -894,6 +896,7 @@ async def changed_head( admission: Any, reuse: Any, topic_reuse: Any, + excluded_memory: Any, ) -> Any: nonlocal calls calls += 1 @@ -907,6 +910,7 @@ async def changed_head( admission=admission, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=excluded_memory, ) raise AtomicMemoryConflictError("Memory changed while expanding retrieval") # noqa: TRY003 @@ -992,6 +996,7 @@ async def fails_on_second_expansion( admission: Any, reuse: Any, topic_reuse: Any, + excluded_memory: Any, ) -> Any: nonlocal calls calls += 1 @@ -1006,6 +1011,7 @@ async def fails_on_second_expansion( admission=admission, reuse=reuse, topic_reuse=topic_reuse, + excluded_memory=excluded_memory, ) if calls == 2: return _RecallRoundOutcome( diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 16e98d4020..1e41283fed 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -29,6 +29,9 @@ ArtifactCreated, ArtifactReference, ArtifactRevision, + BootstrapContext, + BootstrapContextRequest, + BootstrapDeliveryReceipt, CaptureContentSourceRequest, CaptureContentSourceResponse, CommitHandoffRequest, @@ -71,6 +74,7 @@ PrepareHandoffRequest, ProposeExperienceRequest, ProposeSkillRequest, + RecordBootstrapDeliveryRequest, RecordTaskOutcomeRequest, ResolveExternalSkillRequest, ReviseArtifactCandidateRequest, @@ -128,6 +132,7 @@ LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, LIST_REMOTE_SKILL_TARGETS, + PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -136,6 +141,7 @@ PUBLISH_ARTIFACT, PUBLISH_REMOTE_SKILL, RECONCILE_REMOTE_SKILLS, + RECORD_BOOTSTRAP_DELIVERY, RECORD_REMOTE_SKILL_RECEIPT, RECORD_SKILL_USAGE, RECORD_TASK_OUTCOME, @@ -523,12 +529,37 @@ def test_prepared_context_is_a_generic_typed_operation_outside_the_mcp_memory_to "query", "max_bytes", "assembly", + "bootstrap_receipt_id", "include_code", } assert set(schemas["PreparedContext"]["properties"]) == {"schema", "status", "content", "content_bytes"} assert not {"memory", "mode", "selection"} & set(schemas["PreparedContext"]["properties"]) +def test_bootstrap_context_has_typed_prepare_and_delivery_operations() -> None: + assert PREPARE_BOOTSTRAP_CONTEXT.path == "/v1/context/bootstrap" + assert PREPARE_BOOTSTRAP_CONTEXT.request_type is BootstrapContextRequest + assert PREPARE_BOOTSTRAP_CONTEXT.response_type is BootstrapContext + assert PREPARE_BOOTSTRAP_CONTEXT.success_status == 200 + assert RECORD_BOOTSTRAP_DELIVERY.path == "/v1/context/bootstrap/receipts" + assert RECORD_BOOTSTRAP_DELIVERY.request_type is RecordBootstrapDeliveryRequest + assert RECORD_BOOTSTRAP_DELIVERY.response_type is BootstrapDeliveryReceipt + + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + schemas = contract["components"]["schemas"] + assert schemas["BootstrapContextRequest"]["properties"]["enabled"]["default"] is False + assert schemas["BootstrapContextRequest"]["properties"]["max_bytes"]["maximum"] == 8192 + assert schemas["BootstrapContext"]["properties"]["items"]["maxItems"] == 7 + assert schemas["BootstrapContextLifecycle"]["enum"] == [ + "startup", + "resume", + "clear", + "compact", + "restore", + "fork", + ] + + def test_experience_skill_and_review_operations_are_typed_and_family_routed() -> None: review_operations = ( LIST_ARTIFACT_CANDIDATES,