diff --git a/integrations/claude-code/plugins/powercontext/claude_code_settings.py b/integrations/claude-code/plugins/powercontext/claude_code_settings.py index ed968b2ce0..9b9ae6f24b 100644 --- a/integrations/claude-code/plugins/powercontext/claude_code_settings.py +++ b/integrations/claude-code/plugins/powercontext/claude_code_settings.py @@ -55,8 +55,8 @@ class ClaudeCodePluginSettings: context_assembly: dict[str, object] | None = None capture_prompts: bool = True flush_on_capture: bool = False - request_timeout_seconds: float = 1.0 - http_budget_seconds: float = 4.0 + request_timeout_seconds: float = 3.0 + http_budget_seconds: float = 6.0 flush_max_calls: int = 4 allow_insecure_http: bool | None = None @@ -122,11 +122,11 @@ def from_environment(cls, *, server_url: str | None = None) -> ClaudeCodePluginS ), request_timeout_seconds=_environment_float( "POWERCONTEXT_CLAUDE_REQUEST_TIMEOUT_SECONDS", - default=1.0, + default=3.0, ), http_budget_seconds=_environment_float( "POWERCONTEXT_CLAUDE_HTTP_BUDGET_SECONDS", - default=4.0, + default=6.0, ), flush_max_calls=_environment_int( "POWERCONTEXT_CLAUDE_FLUSH_MAX_CALLS", diff --git a/integrations/codex/plugins/powercontext/settings.py b/integrations/codex/plugins/powercontext/settings.py index f9f9b9ee32..120bbe452c 100644 --- a/integrations/codex/plugins/powercontext/settings.py +++ b/integrations/codex/plugins/powercontext/settings.py @@ -123,8 +123,8 @@ class CodexPluginSettings(BaseSettings): context_assembly: dict[str, Any] | None = None capture_prompts: bool = True flush_on_capture: bool = False - request_timeout_seconds: float = Field(default=1.0, gt=0) - http_budget_seconds: float = Field(default=4.0, gt=0) + request_timeout_seconds: float = Field(default=3.0, gt=0) + http_budget_seconds: float = Field(default=6.0, gt=0) flush_max_calls: int = Field(default=4, ge=1, le=16) @field_validator("allow_insecure_http", mode="before") diff --git a/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_settings.py b/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_settings.py index 69cc1b4195..3610e8946f 100644 --- a/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_settings.py +++ b/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_settings.py @@ -43,8 +43,8 @@ class WorkBuddyPluginSettings: context_assembly: dict[str, object] | None = None capture_prompts: bool = True flush_on_capture: bool = False - request_timeout_seconds: float = 1.0 - http_budget_seconds: float = 4.0 + request_timeout_seconds: float = 3.0 + http_budget_seconds: float = 6.0 flush_max_calls: int = 4 allow_insecure_http: bool | None = None @@ -86,11 +86,11 @@ def from_environment(cls) -> WorkBuddyPluginSettings: ), request_timeout_seconds=_environment_float( "POWERCONTEXT_WORKBUDDY_REQUEST_TIMEOUT_SECONDS", - default=1.0, + default=3.0, ), http_budget_seconds=_environment_float( "POWERCONTEXT_WORKBUDDY_HTTP_BUDGET_SECONDS", - default=4.0, + default=6.0, ), flush_max_calls=_environment_int( "POWERCONTEXT_WORKBUDDY_FLUSH_MAX_CALLS", diff --git a/src/powercontext/builtin/artifacts/memory/canonical.py b/src/powercontext/builtin/artifacts/memory/canonical.py index 1766fa7f95..51cb8c58d2 100644 --- a/src/powercontext/builtin/artifacts/memory/canonical.py +++ b/src/powercontext/builtin/artifacts/memory/canonical.py @@ -77,6 +77,18 @@ def normalize_text(value: str) -> str: return normalized +def normalize_query(value: str) -> str: + """Normalize one transient retrieval query. + + A query is never persisted, so the durable entry body's UTF-8 byte bound does not + apply to it. Callers that expose a query length limit enforce it on their own terms: + the HTTP contract states its bound in characters, and applying the storage-layer byte + bound here would reject queries the contract accepts. + """ + + return _normalize_non_empty_string(value, "memory search query") + + def normalize_kind(value: str) -> str: """Normalize an open Memory entry kind without closing its value set.""" diff --git a/src/powercontext/builtin/artifacts/memory/service.py b/src/powercontext/builtin/artifacts/memory/service.py index 99753d16a5..a612b02b33 100644 --- a/src/powercontext/builtin/artifacts/memory/service.py +++ b/src/powercontext/builtin/artifacts/memory/service.py @@ -32,6 +32,7 @@ entry_content_hash, memory_content_hash, normalize_kind, + normalize_query, normalize_reason, normalize_text, validate_identifier, @@ -137,6 +138,7 @@ def __init__(self, code: str) -> None: "search-memories": "memory search requires at least one explicit Memory ref", "search-limit": "memory search limit must be positive", "search-mode": "unsupported memory search mode", + "search-query": "memory search query must be non-empty text", } super().__init__(messages[code]) @@ -453,7 +455,10 @@ async def search( memories=selected_memories, capabilities=capabilities, ) - normalized_query = normalize_text(query) + try: + normalized_query = normalize_query(query) + except (TypeError, ValueError) as error: + raise _InvalidMemoryOperationError("search-query") from error query_vector = None profile = None embedding_calls = 0 diff --git a/tests/builtin/artifacts/memory/test_canonical.py b/tests/builtin/artifacts/memory/test_canonical.py index d5f6184177..494be42554 100644 --- a/tests/builtin/artifacts/memory/test_canonical.py +++ b/tests/builtin/artifacts/memory/test_canonical.py @@ -27,6 +27,7 @@ memory_content_hash, normalize_embedding, normalize_kind, + normalize_query, normalize_reason, normalize_text, validate_embedding, @@ -131,6 +132,15 @@ def test_limits_are_measured_after_normalization() -> None: validate_identifier("x" * 129) +def test_retrieval_query_is_not_held_to_the_entry_body_byte_bound() -> None: + """The storage byte bound belongs to durable entry bodies, not transient queries.""" + + assert normalize_query(" quarterly review ") == "quarterly review" + assert normalize_query("界" * 2731) == "界" * 2731 + with pytest.raises(ValueError, match="non-empty"): + normalize_query(" ") + + def test_embedding_validation_rejects_wrong_or_non_finite_vectors() -> None: assert validate_embedding((1, 2.5, -3), dimension=3) == (1.0, 2.5, -3.0) with pytest.raises(ValueError, match="3 dimensions"): diff --git a/tests/e2e/test_context_text_assembly.py b/tests/e2e/test_context_text_assembly.py index a75cf151be..ced1b03e97 100644 --- a/tests/e2e/test_context_text_assembly.py +++ b/tests/e2e/test_context_text_assembly.py @@ -126,6 +126,39 @@ async def scenario(): asyncio.run(scenario()) +def test_multibyte_query_stays_within_the_contract_bound_for_search_and_prepared_context(tmp_path): + """A query inside the contract's character bound must not meet the storage byte bound. + + Both ``/v1/memory/search`` and ``/v1/context/prepare`` accept a query of up to 8192 + characters. The retrieval path once normalized that query through the durable entry + body normalizer, whose 8192-byte limit rejected 2731 non-ASCII characters -- 8193 + UTF-8 bytes, still inside the character bound -- as an internal Server failure. + """ + + async def scenario(): + async with _server(tmp_path) as (_, _, client): + scope = await client.create_scope( + CreateScopeRequest(title="Multibyte query", summary="Character bound", idempotency_key="query-bound") + ) + await client.remember_memory( + RememberMemoryRequest(scope_id=scope.scope_id, kind="fact", text="Budget review is on Thursday.") + ) + + query = "界" * 2731 + assert len(query) < 8192 + assert len(query.encode("utf-8")) > 8192 + + found = await client.search_memory( + SearchMemoryRequest(scope_id=scope.scope_id, query=query, mode=MemorySearchMode.FTS) + ) + prepared = await client.prepare_context(PrepareContextRequest(scope_id=scope.scope_id, query=query)) + + assert found.hits == [] + assert prepared.content is None + + asyncio.run(scenario()) + + def test_client_assembles_approved_evidence_and_preserves_exact_memory_versions(tmp_path, monkeypatch): async def scenario(): async with _server(tmp_path) as (_, transport, client):