Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions integrations/codex/plugins/powercontext/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src/powercontext/builtin/artifacts/memory/canonical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
7 changes: 6 additions & 1 deletion src/powercontext/builtin/artifacts/memory/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
entry_content_hash,
memory_content_hash,
normalize_kind,
normalize_query,
normalize_reason,
normalize_text,
validate_identifier,
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/builtin/artifacts/memory/test_canonical.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
memory_content_hash,
normalize_embedding,
normalize_kind,
normalize_query,
normalize_reason,
normalize_text,
validate_embedding,
Expand Down Expand Up @@ -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"):
Expand Down
33 changes: 33 additions & 0 deletions tests/e2e/test_context_text_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading