diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 451748aee5..4d90fa8c13 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -47,11 +47,11 @@ The native `get_server_info` adapter and full real-Server harness were exercised The native suite passed all 61 tests, including synthetic HTTP regressions for missing/denied/unavailable/invalid/incompatible discovery, optional-field additions, operation membership, duplicate deployment-identity fields, qualification isolation, cancellation, credentials and uncertain writes. A synthetic HTTP regression verifies that a qualified connection blocks Scope requests after discovery returns 503 and allows them again after an explicit successful recheck. The bundled-product qualification test also exercised explicit Scope browsing through the normal manager. All 116 frontend tests passed, including distinct bilingual discovery diagnostics, replacement of a temporary-failure diagnostic after a successful recheck, and per-operation Memory/Scope gating with restriction reasons while independently supported operations remain available, including exact-read gating for records returned by a save. Clippy with `--all-targets -- -D warnings`, Rust formatting, generated contract drift, IPC export drift, frontend lint/type checks/build and seven focused Python contract/fixture tests passed. Generation was executed from a Windows checkout whose path contains a space. Repository hooks passed except default Windows `ty check`, which reported POSIX-only attributes in unchanged Python modules; `ty check --python-platform linux` passed. Synthetic HTTP and UI fixtures establish local status/recovery handling, not installed-host or release acceptance. -The historical Server c535d816 protocol qualification below records the specific source, wheel and operation identities independently of the CI-only fixture. It does not establish installed UI or release acceptance. The focused `server_probe discovery-only` mode calls discovery without readiness or Memory requests and accepts the full probe's fixture format. Historical product records remain unchanged; the locally generated qualification file is a CI-only fixture. This historical discovery qualification retains its original contract digest; the current-contract records retain their ten independently qualified operations and do not add discovery qualification. Deployment-aware read/write fencing, explicit readiness separation, local setup approval handoff and installed Windows/macOS qualification remain separate acceptance requirements. +The historical Server c535d816 protocol qualification below records the specific source, wheel and operation identities independently of the CI-only fixture. It does not establish installed UI or release acceptance. The focused `server_probe discovery-only` mode calls discovery without readiness or Memory requests and accepts the full probe's fixture format. Historical product records remain unchanged; the locally generated qualification file is a CI-only fixture. This historical discovery qualification retains its original contract digest; the `cc5980f0`, `dd62723d` and `4ffdf4e9` records retain their ten independently qualified operations and do not add discovery qualification. The `cc5980f0` acceptance predates the native discovery adapter and does not establish real-Server acceptance of the combined client and Server sources. Deployment-aware read/write fencing, explicit readiness separation, local setup approval handoff and installed Windows/macOS qualification remain separate acceptance requirements. ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract has source-specific qualifications `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` below. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. The `dd62723d` record covers the migration-framework sources; the `4ffdf4e9` record covers the earlier release-preparation sources. The `sqlite-atomic-c535d816-v1`, `sqlite-atomic-98275dbb-v1`, `sqlite-atomic-1da99747-v1`, `sqlite-atomic-8f10c560-v1`, `sqlite-atomic-d9a78567-v1`, `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The bundled contract digest matches `sqlite-atomic-cc5980f0-v1`, which records the ten protocol operations qualified below. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -61,10 +61,100 @@ Selecting a compatibility profile does not attest the remote binary identity. Ex ### Server protocol qualification records +#### Server cc5980f0 protocol qualification + +`sqlite-atomic-cc5980f0-v1` qualifies Server sources at +`cc5980f01610f32b2f86b4d4068dd72a0408a500` for OpenAPI digest +`fd8f5dcf8dfb1a02c186623c08c3c62217ecdbc21a80cd5d9c329f3497b85f7d`. +The wheel `powercontext-1.4.1.dev211+gcc5980f0-py3-none-any.whl` has SHA-256 +`443f5ec8c73148045caadbba09cf6f08cc6e17329fcf9764378ebcc2cb03ba7c`. +It was built from `git archive` of that commit with +`SETUPTOOLS_SCM_PRETEND_VERSION=1.4.1.dev211+gcc5980f0 uv build --wheel`. +All 614 packaged runtime files across `powercontext/` and `powercontext_service_bootstrap/` +match the archived sources byte for byte. Their manifest digest (sorted `wheel/path sha256` +lines with a final newline) is +`ea0a886acb5092548822b43b2dcee7f3e414a5ffba7d6bdc0b193ac311e157c1`. +The OpenAPI contract, project configuration and lock also match that commit. + +On 2026-10-27, macOS 26.6.2 ARM64, Python 3.13.12 and Rust 1.99.1 ran +`desktop/tests/real_server.py` with the freshly built native `server_probe` against +independent SQLite Server processes loaded from that wheel. Anonymous loopback, +static Bearer, HTTPS with an explicit CA/base path, and injected-provider/enforced +access all passed. The ten operations recorded in the profile cover liveness, +readiness, access principal, capabilities, Scope listing/detail/default, Atomic Memory +save/search and exact Artifact revision reads. The same native connection manager also +passed Scope pagination, identity changes, same-identity revocation, committed writes +with lost responses without replay, and independent Server survival after client exit. + +The client Rust sources, Cargo manifest/lock and qualification harnesses match the +recorded commit. The candidate fixture profile supplied the exact wheel/contract identity +to the probe. Evidence is +retained locally in ignored `desktop/.artifacts/real-server.json`, +`desktop/.artifacts/qualification-wheel-identity.json`, +`desktop/.artifacts/qualification-cc5980f0/` and `desktop/.artifacts/server-wheel/`. + +`cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml` passed all 62 tests, +including `bundled_qualification_allows_explicit_scope_browsing`, with this product record. + +This qualifies the listed SQLite protocol operations on that host. Windows execution, +installed UI, native credential persistence, signing and packaging require their own +acceptance checks. Historical profiles retain their original identities and evidence. + +2026-10-27,使用上述提交的归档源码构建独立 wheel,并逐文件核对 614 个运行时文件及 +OpenAPI、项目配置和锁文件。原生探针通过匿名回环、Bearer、显式 CA/路径前缀 HTTPS +及强制访问控制四种真实 SQLite 协议验收,覆盖所列十项操作、Scope 分页、身份变更、 +同身份撤权、响应丢失后不重放已提交写入及客户端退出后 Server 独立存活。该记录仅覆盖 +上述主机的协议行为,不包含 Windows、安装界面、原生凭据持久化、签名或打包验收。 +包含随包兼容资格用例在内的全部 62 项 Rust 测试通过。 + +On 2026-10-28, the macOS ARM64 checkout adapter also passed all four modes against +the same archived wheel. The native `server_probe` executable had SHA-256 +`c88e62723a05419b144282eedd90207cdd1d670a73aef2ec1b84eb94cfdc3d84`. +This run used the disposable CI fixture profile, including `get_server_info`, and +`cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml` passed. Evidence +is retained in ignored `desktop/.artifacts/qualification-cc5980f0-client-oct28/`. +These results verify the checkout adapter against that archived Server; they do +not extend the product profile's ten qualified operations or validate subsequent +Server migration code. + +2026-10-28,macOS ARM64 上的当前检出客户端使用相同归档 wheel,再次通过四种连接模式, +并通过全部 Rust 测试。本次使用包含 `get_server_info` 的临时 CI 资格记录,仅验证客户端 +与该归档 Server 的协议交互,不扩大产品记录的十项操作范围,也不验证后续 Server 迁移代码。 + +#### Server 777bfdb7 protocol qualification + +`sqlite-atomic-777bfdb7-v1` qualifies Server sources at +`777bfdb7ef45e1380a8a9ea6d6639149924b639f` for OpenAPI digest +`f5e455eabb8d0e0353d963141802df5b14a4c1fdbb1a4a64ed1cfab6235730f1`. +The wheel `powercontext-1.4.1.dev202+g777bfdb7-py3-none-any.whl` has SHA-256 +`9b0543d1c907774ea0f35d4b804b87c3f927925eb65661ebc6e7a1a184412199`. +It was built from `git archive` of that commit with the distribution version supplied explicitly. +All 553 packaged runtime files match the archived sources byte for byte. Their manifest digest +(sorted `wheel/path sha256` lines with a final newline) is +`55b6e3fcbb95f0d29d1ccecefd4d757498e305f23df1421b7f529bacedb0c07f`. + +On 2026-10-26, macOS 26.6.2 ARM64, Python 3.13.12 and Rust 1.99.1 ran +`desktop/tests/real_server.py` with the native `server_probe` against independent SQLite +Server processes loaded from that wheel. Anonymous loopback, static Bearer, HTTPS with an +explicit CA/base path, and injected-provider/enforced access all passed. The ten listed +operations cover save/search/exact revision reads, Scope pagination, identity changes, +same-identity revocation, committed writes with lost responses without replay, and independent +Server survival after client exit. The client was built from commit `777bfdb7`; the working +tree also contained a persistence projection change, which was excluded from the archived +Server wheel and does not change the native client. Evidence is retained locally in ignored +`desktop/.artifacts/real-server.json` and `desktop/.artifacts/server-wheel/`. +This qualifies the listed SQLite protocol operations, not manifest descriptions, Windows +execution, native vaults, installed UI, signing or packaging. Historical records keep their +original source, contract and wheel identities. + +2026-10-26,使用上述提交的归档源码构建独立 wheel,并逐文件核对 553 个运行时文件。 +原生探针通过匿名回环、Bearer、显式 CA/路径前缀 HTTPS 及强制访问控制四种真实 SQLite +协议验收。该记录仅覆盖所列十项操作,不包含 manifest 摘要、Windows、安装包或原生凭据库验收。 + #### Server 4ffdf4e9 protocol qualification `sqlite-atomic-4ffdf4e9-v1` qualifies the v1.4.1 preparation sources at -`4ffdf4e97ad43c55d8718a1df581596db2e6097d` for the bundled OpenAPI digest +`4ffdf4e97ad43c55d8718a1df581596db2e6097d` for OpenAPI digest `c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a`. The qualification wheel is `powercontext-1.4.1.dev176+g4ffdf4e97-py3-none-any.whl`, SHA-256 `33079ebc5eb61d03dd4cccc0d104c47b0415beab1502a5b01fbc38f2cc30af2b`, built from that clean commit. diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index aaf7d8133b..149d952b16 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -209,6 +209,44 @@ ], "evidence": "desktop/VALIDATION.md#server-98275dbb-protocol-qualification" }, + { + "id": "sqlite-atomic-777bfdb7-v1", + "serverCommit": "777bfdb7ef45e1380a8a9ea6d6639149924b639f", + "contractSha256": "f5e455eabb8d0e0353d963141802df5b14a4c1fdbb1a4a64ed1cfab6235730f1", + "artifactSha256": "9b0543d1c907774ea0f35d4b804b87c3f927925eb65661ebc6e7a1a184412199", + "operations": [ + "get_liveness", + "get_readiness", + "get_access_principal", + "get_capabilities", + "list_scopes", + "get_scope", + "get_default_scope", + "remember_memory", + "search_memory", + "get_artifact_revision" + ], + "evidence": "desktop/VALIDATION.md#server-777bfdb7-protocol-qualification" + }, + { + "id": "sqlite-atomic-cc5980f0-v1", + "serverCommit": "cc5980f01610f32b2f86b4d4068dd72a0408a500", + "contractSha256": "fd8f5dcf8dfb1a02c186623c08c3c62217ecdbc21a80cd5d9c329f3497b85f7d", + "artifactSha256": "443f5ec8c73148045caadbba09cf6f08cc6e17329fcf9764378ebcc2cb03ba7c", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-cc5980f0-protocol-qualification" + }, { "id": "sqlite-atomic-4ffdf4e9-v1", "serverCommit": "4ffdf4e97ad43c55d8718a1df581596db2e6097d", diff --git a/desktop/src-tauri/src/transport/api.rs b/desktop/src-tauri/src/transport/api.rs index db6e0d7884..1c0a692434 100644 --- a/desktop/src-tauri/src/transport/api.rs +++ b/desktop/src-tauri/src/transport/api.rs @@ -402,6 +402,7 @@ impl ServerApi { text: text.to_owned(), reason: None, expected_revision: None, + description: None, }; let body = serde_json::to_value(request) .map_err(|_| ApiFailure::before(SafeError::InvalidInput))?; diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b041e70422..97d7c66898 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a", + "contractSha256": "fd8f5dcf8dfb1a02c186623c08c3c62217ecdbc21a80cd5d9c329f3497b85f7d", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/src-tauri/src/transport/wire.rs b/desktop/src-tauri/src/transport/wire.rs index 70912c8814..d15439ad89 100644 --- a/desktop/src-tauri/src/transport/wire.rs +++ b/desktop/src-tauri/src/transport/wire.rs @@ -1232,6 +1232,16 @@ pub struct RememberMemoryRequest { skip_serializing_if = "Option::is_none" )] pub expected_revision: Option>, + /// Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model. + #[ts(optional)] + #[ts(type = "string | null")] + #[serde( + rename = "description", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub description: Option>, } impl RememberMemoryRequest { @@ -1243,6 +1253,7 @@ impl RememberMemoryRequest { text, reason: None, expected_revision: None, + description: None, } } } diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index f2ef1ceadf..3787e06727 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -16,6 +16,26 @@ // Generated from openapi/powercontext.yaml. Do not edit. export interface paths { + "/v1/recall/manifest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Read a bounded metadata-only recall manifest + * @description Lists active Atomic Memory metadata only. Empty inventory never proves the Scope has no context or authorizes skipping prepare_context. Fetch selected exact refs with get_artifact_revision. Pagination is live keyset order, not a cross-request snapshot. + */ + post: operations["get_recall_manifest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/atomic-memory/list": { parameters: { query?: never; @@ -2313,14 +2333,47 @@ export interface components { [key: string]: components["schemas"]["FeatureContract"]; }; }; + RecallManifestRequest: { + scope_id: string; + kind?: string | null; + tags?: string[]; + tag_match?: components["schemas"]["TagMatch"]; + /** @default 50 */ + limit: number; + cursor?: string | null; + }; + RecallManifestEntry: { + ref: components["schemas"]["ArtifactReference"]; + kind: string; + /** @description Stored summary, or null for historical records awaiting explicit summary completion. No body is read to synthesize it. */ + description: string | null; + state_version: number; + }; + RecallManifestResponse: { + items: components["schemas"]["RecallManifestEntry"][]; + next_cursor: string | null; + /** @enum {string} */ + code: "page" | "empty_inventory"; + }; /** @enum {string} */ AtomicMemoryState: "active" | "forgotten" | "merged" | "retired"; + AtomicMemoryReadContent: { + /** @default powercontext.atomic-memory.v1 */ + schema: components["schemas"]["AtomicMemoryContentSchema"]; + kind: string; + /** @description Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit. */ + text: string; + /** @description A concise single-line summary of final text, in the same language. Required for new writes. */ + description?: string | null; + }; AtomicMemoryWriteContent: { /** @default powercontext.atomic-memory.v1 */ schema: components["schemas"]["AtomicMemoryContentSchema"]; kind: string; /** @description Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit. */ text: string; + /** @description Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model. */ + description?: string | null; }; CreateAtomicMemoryArtifactRequest: { /** @@ -2914,7 +2967,7 @@ export interface components { version: number; family: components["schemas"]["CandidateFamily"]; status: components["schemas"]["CandidateStatus"]; - proposal: components["schemas"]["ExperienceProposal"] | components["schemas"]["SkillProposal"] | components["schemas"]["ProfileCandidateProposal"] | components["schemas"]["AtomicMemoryWriteContent"] | components["schemas"]["TopicMemoryDreamProposal"] | components["schemas"]["HandoffContent"] | components["schemas"]["PromptContent"] | components["schemas"]["CatalogChangeProposal"]; + proposal: components["schemas"]["ExperienceProposal"] | components["schemas"]["SkillProposal"] | components["schemas"]["ProfileCandidateProposal"] | components["schemas"]["AtomicMemoryReadContent"] | components["schemas"]["TopicMemoryDreamProposal"] | components["schemas"]["HandoffContent"] | components["schemas"]["PromptContent"] | components["schemas"]["CatalogChangeProposal"]; /** @description Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. */ source_refs: components["schemas"]["SourceReference"][]; /** @description Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. */ @@ -4416,6 +4469,8 @@ export interface components { text: string; reason?: string | null; expected_revision?: number | null; + /** @description Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model. */ + description?: string | null; }; RetireMemoryEntryRequest: { scope_id: string; @@ -5358,6 +5413,38 @@ export interface components { } export type $defs = Record; export interface operations { + get_recall_manifest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecallManifestRequest"]; + }; + }; + responses: { + /** @description Operation completed. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RecallManifestResponse"]; + }; + }; + 400: components["responses"]["InvalidRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; list_atomic_memories: { parameters: { query?: never; diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index be6f239d43..b1cfde06ed 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -249,7 +249,11 @@ export type RememberMemoryRequest = { scope_id: string, kind: string, /** * Must not exceed 8192 UTF-8 bytes after normalization. */ -text: string, reason?: string | null, expected_revision?: number | null, }; +text: string, reason?: string | null, expected_revision?: number | null, +/** + * Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model. + */ +description?: string | null, }; export type Role = "leader" | "standby"; export type ScopeDescriptor = { scope_id: string, title: string, summary: string, parent_scope_id?: string | null, context_references: Array, external_references: Array, version: number, }; export type ScopeExternalReference = { kind: string, value: string, }; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 06889f38b5..c6ef2eca0f 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 = "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a"; +export const contractSha256 = "fd8f5dcf8dfb1a02c186623c08c3c62217ecdbc21a80cd5d9c329f3497b85f7d"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index e9ca6724c0..01e5927773 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -16,8 +16,8 @@ The design follows [RFC #1771](https://github.com/oceanbase/powercontext/pull/17 The full Server framework registers two installed-release sources for the PowerContext 1.4.1 target (stored compatibility IDs shown below): -- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004`. -- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004` through a separate frozen source bridge. +- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`. +- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005` through a separate frozen source bridge. Application release numbers and schema revisions are distinct. The 1.4.0 release is not the `s1400` checkpoint: its existing Candidate schema is current, but its Topic attempt storage still needs the registered bridge. @@ -51,6 +51,8 @@ maintenance for existing managed storage. Runtime startup does not add the recei Revision `v1_4_1_004` registers `pc_access_owner_recovery` for durable Artifact and Candidate ownership intents. Both installed-release source paths and vector profiles include this table. Existing managed databases require explicit maintenance; fresh databases create the registered empty table. Pending intents retain their original deployment, principal and idempotency identity across Server restarts. Earlier revision resources and checksums remain unchanged. Unregistered database layouts are rejected rather than adopted from a table name alone. This registration does not establish installed-release or external-Provider upgrade acceptance. +Revision `v1_4_1_005` adds nullable `pc_atomic_memory_current.description` metadata for text and vector profiles. Existing managed databases require explicit migration; startup does not alter this column. The additive revision leaves historical Artifact bytes unchanged and existing descriptions null until a new write or explicit projection rebuild copies authoritative metadata. + An interrupted [portable restore](portable-archive.md) keeps normal startup and workers blocked until its projections are complete. Retry `powercontext archive restore --yes` with the same validated bundle. Only this explicit recovery path can open an incomplete projection set, after validating the registered layout and the configured vector @@ -97,12 +99,14 @@ Sequence `000` identifies a registered historical baseline; upgrade steps start | `v1_4_1_001` | 1.4.1 | 1 | `s1400` | Complete Server storage transition | | `v1_4_1_002` | 1.4.1 | 2 | `s1401` | Offline Memory history archive | | `v1_4_1_003` | 1.4.1 | 3 | `s1402` | Portable restore receipts and timestamp adaptation | +| `v1_4_1_004` | 1.4.1 | 4 | `v1_4_1_004` | Durable owner recovery intents | +| `v1_4_1_005` | 1.4.1 | 5 | `v1_4_1_005` | Nullable Atomic Memory description projection | -Both registered source paths reach `v1_4_1_003`; only 1.3.0 is selected for CI upgrade acceptance: +Both registered source paths reach `v1_4_1_005`; only 1.3.0 is selected for CI upgrade acceptance: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 ``` `status` and `plan` show `source_migration`, `target_migration` and `pending_migrations` @@ -110,7 +114,7 @@ before the existing machine fields. `apply` and `verify` show `migration`. Each contains `name`, `software_version`, `sequence` and the compatible `revision` ID. For example: ```json -{"revision":"s1402","name":"v1_4_1_003","software_version":"1.4.1","sequence":3} +{"revision":"v1_4_1_005","name":"v1_4_1_005","software_version":"1.4.1","sequence":5} ``` The software version describes the registered database state, not the currently running binary @@ -124,7 +128,7 @@ existing plans and original backups usable. Partial prototype bundles keep their For a new Server migration, use the release-based name directly as its Alembic `revision` and register its schema, domain steps and recovery checkpoints as usual. `down_revision` must reference -the actual preceding ID (`s1402` when appending directly to the current head), even if that ID has +the actual preceding ID (`v1_4_1_005` when appending directly to the current head), even if that ID has a display alias. The framework follows this registered chain, not lexical name ordering. Already registered names and release assignments stay immutable; a version bump must not rename history. diff --git a/docs/en/docs/workflows/atomic-memory.md b/docs/en/docs/workflows/atomic-memory.md index 11fa8a5337..fee09467b3 100644 --- a/docs/en/docs/workflows/atomic-memory.md +++ b/docs/en/docs/workflows/atomic-memory.md @@ -21,7 +21,8 @@ Submit to `POST /v1/scopes/S/artifacts`: "content": { "schema": "powercontext.atomic-memory.v1", "kind": "decision", - "text": "Keep the public API asynchronous." + "text": "Keep the public API asynchronous.", + "description": "Asynchronous public API" } } ``` @@ -42,7 +43,8 @@ Submit complete content to `PUT /v1/scopes/S/artifacts/atomic-memory/M`, sending { "content": { "kind": "decision", - "text": "Keep the public API asynchronous and provide a separate internal synchronous adapter." + "text": "Keep the public API asynchronous and provide a separate internal synchronous adapter.", + "description": "Async public API with a separate internal synchronous adapter" } } ``` @@ -271,3 +273,62 @@ authorization failures stop processing. The immutable Owner columns are only an Scope checks using the same database with supported Builtin or Casbin providers share the business snapshot. Independent decision stores and custom providers retain their configured read consistency contracts. + + +## Metadata-only recall manifest + +`POST /v1/recall/manifest` (MCP/Python client: `get_recall_manifest`) lists metadata for active Atomic Memory only. +It does not cover Topic Memory, Experience, Skill, or Sources and does not replace `prepare_context`. + +```json +{"scope_id": "S", "limit": 50, "kind": "decision"} +``` + +The response contains `items`, `next_cursor`, and `code` (`page` or `empty_inventory`). Each item has an exact `ref`, +`kind`, `description`, and `state_version`. Select useful refs, then use `get_artifact_revision` to read and cite their +full content. A description is a selection aid, not evidence. An empty filtered page never proves a Scope empty or +permits skipping `prepare_context`. + +The cursor binds request identity and filters through a fixed-length fingerprint, so long Unicode tags do not +expand the returned token beyond the accepted 4,096-character limit. The fingerprint preserves exact Unicode filter +values, including distinctions between composed and decomposed strings. Cursors issued before a cursor-format upgrade +are rejected; restart pagination from the first page. + +The limit defaults to 50 and accepts 1–100; oversized requests fail explicitly. Copy `next_cursor` into the next +request with the same Scope, principal, kind and tag filters. Cursors are signed and endpoint-bound. Ordering is by +Artifact ID using live keyset pagination, not a snapshot across requests: concurrent new IDs behind the cursor +require restarting traversal. A null cursor is the explicit end marker. Scope read permission is required. The primary key bounds traversal by Scope and ID; kind/tag filters may +scan more metadata rows than the returned page. This interface does not promise task-success or latency gains. + +Every successful new write, including direct creation/replacement, merges and `remember_memory`, contains a nonblank, +single-line `description` of 1–200 Unicode characters in the same language as final text. Callers may supply it. +When omitted, the write path collapses whitespace in the final normalized text into single spaces and saves a +prefix of at most 200 Unicode characters (including a trailing `…` when truncated). This is a default preview, +not a model-generated summary. Explicit writes do not call a generation model, even when one is configured; +existing integrations can continue sending only text. Automatic extraction and +reconciliation generate descriptions alongside text in their existing call; they need no extra summary request. +Invalid generation output fails instead of silently publishing incomplete metadata. Custom Atomic Memory +prompt demonstrations must be updated for the v2 extraction/reconciliation definitions. + +Default previews are applied during write preparation, before content hashes and search projections are fixed. +The commit boundary rejects a prepared new write that still lacks a description; it does not apply defaults +or alter prepared content. Historical restoration preserves the stored content without adding a summary. + +Descriptions are authoritative revision content and copied transactionally to `pc_atomic_memory_current.description`. +Manifest queries select narrow metadata columns, never `text`, `searchable_text`, vectors or `pc_artifacts.content`. +Neither reading the manifest nor opening an existing database generates summaries. Registered migration `v1_4_1_005` +adds the nullable projection column without scanning bodies; existing managed databases require explicit migration. +Existing projection rows remain null until explicit projection repair or a new write. +Topic recovery initialization accepts both the original pristine Atomic maintenance layout and its exact nullable-description +variant; unknown columns, changed FTS objects, and existing Atomic evidence remain rejected. +The existing offline `atomic-memory-rebuild-projection` command copies stored descriptions; it never generates new ones. +Its full verification/rebuild scans authoritative history and current rows and may re-embed active memories: this is +maintenance work, not part of incremental writes or manifest reads. + +Historical revisions without a description remain readable and serialize without an extra null field. A null manifest +description explicitly means missing metadata, not a missing memory. To complete a historical summary, an authorized +writer reads the current revision and submits complete unchanged kind/text through ordinary replacement with the +current `If-Match` ETag. Supply a reviewed summary or omit it to store the bounded text preview. This creates a new revision; a stale ETag is +rejected. Never update historical JSON or its content hash in place. Restoring an old revision preserves its historical +absence of summary. Size, token estimates, timestamps, provenance summaries and other families are outside this first +manifest contract; no such field is computed by reading bodies. diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index f511938a7e..204305cca7 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -14,8 +14,8 @@ description: 了解数据库迁移命令、备份策略、服务启停及当前 完整 Server 框架登记了两个真实安装包来源: -- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004`。 -- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004`。 +- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 +- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 应用发布版本与 schema revision 分别编号。1.4.0 发行包不等于 `s1400` 检查点:其 Candidate 结构已经是当前格式, 但 Topic 尝试状态仍需登记的桥接。两条路径保留既有引用和历史,到达相同的受管目标结构;原有资源摘要分别保留, @@ -44,6 +44,8 @@ payload 身份及待处理 intent。兼容的当前任务原样保留,不套 修订 `v1_4_1_004` 将 `pc_access_owner_recovery` 纳入管理,用于持久保存 Artifact 和 Candidate 的所有权写入意图。两条已登记发布源路径及向量配置均包含此表。已有受管数据库需要显式维护,新数据库创建已登记的空表。待恢复意图在 Server 重启后保留原有 deployment、Principal 与幂等身份;历史修订资源及校验和保持不变。未登记布局不会仅凭表名被自动接纳。此登记不代表已通过安装发布版或外部 Provider 的升级验收。 +`v1_4_1_005` 为文本和向量配置添加可空的 `pc_atomic_memory_current.description` 列。已有受管库需要显式迁移,启动不会修改该列。新增列不改写历史 Artifact 内容;已有投影的摘要保持空值,直到新写入或显式投影重建复制权威摘要。 + [可移植归档恢复](portable-archive.md) 中断后,普通启动和 worker 会继续拒绝未完成的检索投影。 请使用同一个通过校验的归档,重新执行 `powercontext archive restore --yes`。 仅此显式恢复路径可在验证已登记结构和配置的向量 profile 后打开投影尚未完整的数据库,且不启动 worker。 @@ -82,12 +84,14 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 | `v1_4_1_001` | 1.4.1 | 第一步 | `s1400` | 完整 Server 存储转换 | | `v1_4_1_002` | 1.4.1 | 第二步 | `s1401` | 离线 Memory 历史归档 | | `v1_4_1_003` | 1.4.1 | 第三步 | `s1402` | 可移植恢复回执与时间戳适配 | +| `v1_4_1_004` | 1.4.1 | 第四步 | `v1_4_1_004` | 持久化所有权恢复意图 | +| `v1_4_1_005` | 1.4.1 | 第五步 | `v1_4_1_005` | Atomic Memory 可空摘要投影列 | -两条已登记来源路径都升级到 `v1_4_1_003`;CI 升级验收只选择 1.3.0: +两条已登记来源路径都升级到 `v1_4_1_005`;CI 升级验收只选择 1.3.0: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 ``` `status` 和 `plan` 在原有机器字段之前显示 `source_migration`、`target_migration`、 @@ -95,7 +99,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 `software_version`、`sequence` 和兼容保留的 `revision` ID。例如: ```json -{"revision":"s1402","name":"v1_4_1_003","software_version":"1.4.1","sequence":3} +{"revision":"v1_4_1_005","name":"v1_4_1_005","software_version":"1.4.1","sequence":5} ``` 这里的软件版本描述已登记的数据库状态,不代表当前运行的软件包版本,也不代表中断维护窗口最初的来源。 @@ -106,7 +110,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 这样可以继续使用已有计划和原始备份。局部原型 bundle 保留自己的独立编号。 后续新增 Server 迁移直接使用该命名作为 Alembic `revision`,并正常登记结构、领域步骤和恢复检查点。 -`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,仍引用 `s1402`,即使它已有展示别名。 +`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_4_1_005`,即使它已有展示别名。 框架按照登记的链执行,不按名称字典序推算。已登记的名称和所属发布版本保持不变,版本升级不能重命名历史。 ## 初始化与维护职责 diff --git a/docs/zh/docs/workflows/atomic-memory.md b/docs/zh/docs/workflows/atomic-memory.md index 99f4d83704..2efa2a3d6f 100644 --- a/docs/zh/docs/workflows/atomic-memory.md +++ b/docs/zh/docs/workflows/atomic-memory.md @@ -20,7 +20,8 @@ Atomic Memory 将每条事实、偏好或决策保存为独立的 `atomic-memory "content": { "schema": "powercontext.atomic-memory.v1", "kind": "decision", - "text": "公开 API 保持异步。" + "text": "公开 API 保持异步。", + "description": "公开 API 保持异步" } } ``` @@ -40,7 +41,8 @@ Atomic Memory 将每条事实、偏好或决策保存为独立的 `atomic-memory { "content": { "kind": "decision", - "text": "公开 API 保持异步,内部同步适配器另行提供。" + "text": "公开 API 保持异步,内部同步适配器另行提供。", + "description": "公开 API 异步,内部同步适配器独立提供" } } ``` @@ -251,3 +253,49 @@ unsupported 通过 `ServerResponseError.status_code`、`.code`、`.details` 检 使用同一数据库的受支持 Builtin 或 Casbin 服务进行 Scope 检查时,授权读取与业务数据共享快照。 独立的决策存储和自定义服务保留各自配置的读取一致性契约。 + + +## 只读元数据的召回清单 + +`POST /v1/recall/manifest`(MCP/Python 客户端:`get_recall_manifest`)只列出当前活跃 Atomic Memory 的元数据, +不覆盖 Topic Memory、Experience、Skill 或 Source,也不替代 `prepare_context`。 + +```json +{"scope_id": "S", "limit": 50, "kind": "decision"} +``` + +响应包含 `items`、`next_cursor` 和 `code`(`page` 或 `empty_inventory`)。每项包含精确版本引用 `ref`、 +`kind`、`description` 和 `state_version`。选择相关引用后,通过 `get_artifact_revision` 读取正文并引用证据。 +摘要只帮助选择,不是证据。筛选后的空清单不能证明 Scope 为空,也不允许跳过 `prepare_context`。 + +游标通过固定长度指纹绑定请求身份和筛选条件,长 Unicode 标签不会使返回的游标超出请求允许的 4,096 字符上限。 +指纹保留 Unicode 筛选值的原始形式,包括组合形式与分解形式之间的区别。 +游标格式升级前签发的游标会被拒绝,需要从第一页重新分页。 + +每页默认 50 条,允许 1–100 条,超限明确报错。翻页时携带 `next_cursor`,保持 Scope、调用主体、类型和标签筛选一致。 +游标带签名并绑定接口,按 Artifact ID 进行实时键集分页,不是跨请求快照;并发新增且 ID 排在游标之前的记录需要重新遍历。 +`next_cursor: null` 明确表示末页。清单读取要求 Scope 读取权限。主键按 Scope 和 ID 限定遍历范围;类型或标签筛选可能扫描多于返回数量的元数据行, +此接口不承诺任务成功率或延迟收益。 + +新增、修订、合并和 `remember_memory` 成功写入时都包含 `description`:与最终正文同语言、非纯空白、单行、1–200 个 Unicode 字符。 +调用者可以提供摘要;省略时,写入路径将最终规范化正文中的连续空白折叠为空格,保存最多 200 个 Unicode 字符的前缀, +超长时末尾用 `…` 标记截断,省略号计入长度上限。这是默认文本简介,不是模型生成的语义摘要。 +显式写入不会调用生成模型,即使已经配置模型也一样;旧集成仍可只提交正文。 +自动抽取和协调在原有模型调用中同时生成正文和摘要,不增加额外摘要请求;格式不合格时拒绝生成结果,不静默发布不完整数据。自定义 Atomic Memory 提示词示例需要适配 v2 抽取和协调定义。 + +默认文本简介在写入准备阶段补齐,之后才固定内容哈希和搜索投影。提交边界拒绝仍缺少简介的 +已准备新增写入,不会在提交时再次补默认值或改动已准备内容。恢复历史版本保留已保存的内容,不补写摘要。 + +权威摘要保存在对应 Artifact 内容版本中,并在同一事务中复制到 `pc_atomic_memory_current.description`。 +清单查询只选择窄元数据列,不读取 `text`、`searchable_text`、向量或 `pc_artifacts.content`。 +清单读取和数据库启动都不会生成摘要。已登记迁移 `v1_4_1_005` 添加可空投影列,不扫描正文;已有受管库需要显式迁移。 +已有投影行在显式修复或新写入前保持空值。 +Topic 恢复初始化同时识别原始的空 Atomic 维护结构和仅新增可空简介列的结构;未知列、改动的 FTS 对象和已有 Atomic 证据仍被拒绝。 +现有离线 `atomic-memory-rebuild-projection` 命令只复制已保存的摘要,不生成新摘要;完整校验和重建会扫描权威历史和当前行, +并可能重新生成活跃记忆的向量,属于维护工作,不在增量写入或清单查询中执行。 + +没有摘要的历史版本仍可读取,序列化不会多出空摘要字段。清单中的空摘要表示元数据尚未补齐,不表示记忆不存在。 +补齐时,由有权限的写入者读取当前版本,携带当前 `If-Match` ETag,通过普通替换接口提交原有类型和正文。 +可以提供已检查的摘要,或省略摘要以保存有界的正文简介。 +这会创建新版本,过期 ETag 会被拒绝;不得原地修改历史 JSON 或内容哈希。恢复旧版本会保留当时缺少摘要的状态。 +大小、Token 估算、时间戳、来源概要和其他记忆类型不在第一版契约中,不通过读取正文临时计算。 diff --git a/evaluation/memory/atomic_extraction/README.md b/evaluation/memory/atomic_extraction/README.md index e32327d7cb..82723e7673 100644 --- a/evaluation/memory/atomic_extraction/README.md +++ b/evaluation/memory/atomic_extraction/README.md @@ -45,6 +45,9 @@ The runner records the exact built-in Prompt versions, instruction SHA-256 value SHA-256, provider/model identity, safe model settings, timeout, request limit, candidate kind/text/evidence IDs, and portable usage. Provider credentials and raw provider responses are never recorded. +Candidate descriptions are validated against the production content contract. Private observations retain the +kind, text and evidence IDs used by the factual-retention and instruction-leakage scorer. + `results/` is ignored because `observations.private.jsonl` contains generated candidate text. `report.json` and `report.md` contain aggregate counts only. A provider failure is `incomplete`, not an instruction-safety pass. @@ -66,3 +69,9 @@ uv run python -m evaluation.memory.atomic_extraction pin \ The `pin` command validates the strict run manifest, re-scores the private observations, requires a complete passing run, and regenerates the public report rather than copying it. The PR description should link the pinned directory and summarize the provider/model/settings and two quality gates. Do not copy or commit `observations.private.jsonl`. + +Top-level directories under `reports/` are the current evaluation baseline and must match the current dataset and +exact Prompt identities. When either contract changes, archive the previous report directory unchanged under +`reports/archive/` and pin a new passing real-provider run. Archived reports remain bound to their original run +manifests; they do not establish acceptance of the current contract. A changed version string or digest without a +new run is not evaluation evidence. diff --git a/evaluation/memory/atomic_extraction/models.py b/evaluation/memory/atomic_extraction/models.py index a5bd420d31..91f6947682 100644 --- a/evaluation/memory/atomic_extraction/models.py +++ b/evaluation/memory/atomic_extraction/models.py @@ -185,7 +185,8 @@ class CandidateObservation(BaseModel): @classmethod def from_candidate(cls, candidate: AtomicMemoryCandidate) -> CandidateObservation: - return cls.model_validate(candidate.model_dump(mode="json")) + # The observation schema retains the fields consumed by the scorer. + return cls(kind=candidate.kind, text=candidate.text, evidence_ids=candidate.evidence_ids) class CaseObservation(BaseModel): diff --git a/evaluation/memory/atomic_extraction/reports/README.md b/evaluation/memory/atomic_extraction/reports/README.md index 5809afb3e0..a662ee3374 100644 --- a/evaluation/memory/atomic_extraction/reports/README.md +++ b/evaluation/memory/atomic_extraction/reports/README.md @@ -7,3 +7,11 @@ in the ignored `results/` directory. Use the package's `pin` command after reviewing a passing run. The command validates the run identity, re-scores the private observations, requires a complete pass, and regenerates only the safe artifacts; it never copies `observations.private.jsonl`. + +Top-level report directories form the current baseline. Automated checks require at least one and verify every +current report against the exact dataset and Prompt contract, including instruction and compiled digests. + +When the contract changes, move the complete previous directory unchanged into `archive/` and pin a new passing +real-provider run at the top level. Historical reports retain their original versions, identities and scores. +Their reports must match their own run manifests, but they do not certify the current contract. Never update an +old manifest or score to make it appear to come from a new run. diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261019-03/report.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261019-03/report.json similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261019-03/report.json rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261019-03/report.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261019-03/report.md b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261019-03/report.md similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261019-03/report.md rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261019-03/report.md diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261019-03/run-manifest.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261019-03/run-manifest.json similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261019-03/run-manifest.json rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261019-03/run-manifest.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/report.json b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/report.json new file mode 100644 index 0000000000..5e0c55a47e --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/report.json @@ -0,0 +1,78 @@ +{ + "content_policy": "aggregate_only_no_evidence_or_generated_candidate_text", + "dataset": { + "case_count": 9, + "dataset_version": "powercontext.atomic-memory-extraction.synthetic.v1", + "manifest_sha256": "47e6a9e731523b6b41295cf59f6eb4772e69b1afddfa1edebfbf1fe6db021ab4", + "schema_version": "powercontext.atomic-memory-extraction-cases.v1" + }, + "dispositions": { + "atomic_memory": { + "failed": 0, + "passed": 6, + "total": 6 + }, + "experience_or_skill": { + "failed": 0, + "passed": 1, + "total": 1 + }, + "reject": { + "failed": 0, + "passed": 2, + "total": 2 + } + }, + "factual_retention": { + "failed": 0, + "false_rejections": 0, + "passed": 6, + "total": 6, + "unobserved": 0 + }, + "failure_classes": {}, + "instruction_leakage": { + "leaks": 0, + "passed": 9, + "total": 9, + "unobserved": 0 + }, + "overall": { + "failed": 0, + "passed": 9, + "total": 9 + }, + "prompts": { + "coding": { + "builtin_version": "powercontext.atomic-memory.extract.coding.v3", + "compiled_digest": "656f82727896c3d5b8793ae2b9d210f143aa1e8ca760f7f5225e72d663fb083f", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "49f89a1a61e5f551780f934bf447b353a199545fa547ac6aee0a1e8c0e34aee8", + "key": "atomic_memory.extract" + }, + "conversation": { + "builtin_version": "powercontext.atomic-memory.extract.conversation.v3", + "compiled_digest": "8d023c1bb94c724da1613dc9061d3e546815bd34cdf390ebcd8af0deff08d2eb", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "44c6c4969c7c4b984d845af1c4e94270d556443e133e862650ad226a7f508475", + "key": "atomic_memory.extract" + } + }, + "provider": { + "base_url_configured": true, + "base_url_sha256": "0413b53d28826c51b400bc9ebc578639bf6a4ff94d3e43fbcbc468bd51945602", + "header_names": [ + "Authorization" + ], + "max_requests": 2, + "model": "openai-chat:glm-5.3", + "model_settings": {}, + "model_settings_sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "provider": "openai-chat", + "timeout_seconds": 120.0 + }, + "run_id": "atomic-extraction-20261029-19539511-7452a4ba", + "run_manifest_sha256": "17d4e90b1ae7a4385e1e9fd780f992818536af62cc0cb785849740f1a0a30930", + "schema_version": "powercontext.atomic-memory-extraction-report.v1", + "status": "passed" +} diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/report.md b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/report.md new file mode 100644 index 0000000000..75a970187b --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/report.md @@ -0,0 +1,23 @@ +# Atomic Memory extraction live-run report + +- Status: `passed` +- Run: `atomic-extraction-20261029-19539511-7452a4ba` +- Dataset: `powercontext.atomic-memory-extraction.synthetic.v1` (`47e6a9e731523b6b41295cf59f6eb4772e69b1afddfa1edebfbf1fe6db021ab4`) +- Provider/model: `openai-chat` / `openai-chat:glm-5.3` +- Overall: 9/9 passed + +## Quality gates + +- Factual retention: 6/6 passed; false rejections: 0 +- Instruction leakage: 0 leaks across 9 cases; unobserved: 0 + +## Prompt identities + +- `coding`: `powercontext.atomic-memory.extract.coding.v3` / `49f89a1a61e5f551780f934bf447b353a199545fa547ac6aee0a1e8c0e34aee8` +- `conversation`: `powercontext.atomic-memory.extract.conversation.v3` / `44c6c4969c7c4b984d845af1c4e94270d556443e133e862650ad226a7f508475` + +## Failure classes + +- None + +This report contains aggregate counts and run identities only. Evidence and generated candidate text remain in the ignored private run artifact. diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/run-manifest.json b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/run-manifest.json new file mode 100644 index 0000000000..ffb7518461 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261029-19539511-7452a4ba/run-manifest.json @@ -0,0 +1,40 @@ +{ + "dataset": { + "case_count": 9, + "dataset_version": "powercontext.atomic-memory-extraction.synthetic.v1", + "manifest_sha256": "47e6a9e731523b6b41295cf59f6eb4772e69b1afddfa1edebfbf1fe6db021ab4", + "schema_version": "powercontext.atomic-memory-extraction-cases.v1" + }, + "prompts": { + "coding": { + "builtin_version": "powercontext.atomic-memory.extract.coding.v3", + "compiled_digest": "656f82727896c3d5b8793ae2b9d210f143aa1e8ca760f7f5225e72d663fb083f", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "49f89a1a61e5f551780f934bf447b353a199545fa547ac6aee0a1e8c0e34aee8", + "key": "atomic_memory.extract" + }, + "conversation": { + "builtin_version": "powercontext.atomic-memory.extract.conversation.v3", + "compiled_digest": "8d023c1bb94c724da1613dc9061d3e546815bd34cdf390ebcd8af0deff08d2eb", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "44c6c4969c7c4b984d845af1c4e94270d556443e133e862650ad226a7f508475", + "key": "atomic_memory.extract" + } + }, + "provider": { + "base_url_configured": true, + "base_url_sha256": "0413b53d28826c51b400bc9ebc578639bf6a4ff94d3e43fbcbc468bd51945602", + "header_names": [ + "Authorization" + ], + "max_requests": 2, + "model": "openai-chat:glm-5.3", + "model_settings": {}, + "model_settings_sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "provider": "openai-chat", + "timeout_seconds": 120.0 + }, + "run_id": "atomic-extraction-20261029-19539511-7452a4ba", + "schema_version": "powercontext.atomic-memory-extraction-run.v1", + "started_at": "2026-10-29T09:27:36.565679Z" +} diff --git a/evaluation/memory/atomic_extraction/runner.py b/evaluation/memory/atomic_extraction/runner.py index 94f8c329c9..e4808c0f22 100644 --- a/evaluation/memory/atomic_extraction/runner.py +++ b/evaluation/memory/atomic_extraction/runner.py @@ -130,7 +130,7 @@ async def _observe_case( try: result = await generator.generate(_extraction_input(case)) for candidate in result.output.candidates: - AtomicMemoryContent(kind=candidate.kind, text=candidate.text) + AtomicMemoryContent(kind=candidate.kind, text=candidate.text, description=candidate.description) return CaseObservation( case_id=case.case_id, status="succeeded", diff --git a/evaluation/memory/atomic_extraction/tests/test_manifest.py b/evaluation/memory/atomic_extraction/tests/test_manifest.py index 286a177140..050d5b7cb7 100644 --- a/evaluation/memory/atomic_extraction/tests/test_manifest.py +++ b/evaluation/memory/atomic_extraction/tests/test_manifest.py @@ -13,6 +13,7 @@ # limitations under the License. import json +from hashlib import sha256 from pathlib import Path from evaluation.memory.atomic_extraction.manifest import load_manifest @@ -85,3 +86,22 @@ def test_pinned_reports_match_the_current_dataset_and_prompt_contract() -> None: assert manifest.dataset.case_count == len(loaded.manifest.cases) assert manifest.prompts == prompt_run_identities() assert report["dataset"] == manifest.dataset.model_dump(mode="json") + + +def test_archived_reports_remain_bound_to_their_recorded_run() -> None: + archive = Path(__file__).resolve().parents[1] / "reports" / "archive" + assert archive.is_dir() + pinned = sorted(path for path in archive.iterdir() if (path / "run-manifest.json").is_file()) + + assert pinned + for directory in pinned: + manifest = LiveRunManifest.model_validate_json((directory / "run-manifest.json").read_text(encoding="utf-8")) + recorded = manifest.model_dump(mode="json") + report = json.loads((directory / "report.json").read_text(encoding="utf-8")) + + assert report["run_id"] == manifest.run_id + assert report["dataset"] == recorded["dataset"] + assert report["prompts"] == recorded["prompts"] + assert report["provider"] == recorded["provider"] + encoded = json.dumps(recorded, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + assert report["run_manifest_sha256"] == sha256(encoded).hexdigest() diff --git a/evaluation/memory/atomic_extraction/tests/test_runner.py b/evaluation/memory/atomic_extraction/tests/test_runner.py index 06b4144946..e6e952589c 100644 --- a/evaluation/memory/atomic_extraction/tests/test_runner.py +++ b/evaluation/memory/atomic_extraction/tests/test_runner.py @@ -22,11 +22,29 @@ from evaluation.memory.atomic_extraction import runner from evaluation.memory.atomic_extraction.manifest import load_manifest +from evaluation.memory.atomic_extraction.models import CandidateObservation +from powercontext.builtin.artifacts.atomic_memory.extraction import AtomicMemoryCandidate _SECRET_SENTINEL = "Bearer private-provider-secret" # noqa: S105 - synthetic leak sentinel _CONTENT_SENTINEL = "PRIVATE_PREDICTED_SOURCE_MARKER" +def test_candidate_observation_accepts_the_description_contract() -> None: + candidate = AtomicMemoryCandidate( + kind="fact", + text="The project uses SQLite.", + description="Project database choice", + evidence_ids=("source-1",), + ) + + observation = CandidateObservation.from_candidate(candidate) + + assert observation.kind == candidate.kind + assert observation.text == candidate.text + assert observation.evidence_ids == candidate.evidence_ids + assert CandidateObservation.model_validate_json(observation.model_dump_json()) == observation + + def test_provider_bootstrap_failure_writes_incomplete_content_free_report( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/evaluation/memory/capture_ownership/tests/test_replay.py b/evaluation/memory/capture_ownership/tests/test_replay.py index ce7a56ec52..835019cca2 100644 --- a/evaluation/memory/capture_ownership/tests/test_replay.py +++ b/evaluation/memory/capture_ownership/tests/test_replay.py @@ -71,7 +71,18 @@ async def respond(_messages, _info): calls += 1 if calls == 2: raise RuntimeError("No provider response is available") - candidates = [] if calls == 3 else [{"kind": "fact", "text": "Synthetic fact", "evidence_ids": ["missing"]}] + candidates = ( + [] + if calls == 3 + else [ + { + "kind": "fact", + "text": "Synthetic fact", + "description": "Synthetic fact summary", + "evidence_ids": ["missing"], + } + ] + ) return ModelResponse( parts=[TextPart(json.dumps({"candidates": candidates}))], usage=RequestUsage(input_tokens=100 + calls, output_tokens=5 + 2 * calls), diff --git a/evaluation/skills/skill-up/evals/cases/empty-search.yaml b/evaluation/skills/skill-up/evals/cases/empty-search.yaml index 545a451de9..6ed70794e9 100644 --- a/evaluation/skills/skill-up/evals/cases/empty-search.yaml +++ b/evaluation/skills/skill-up/evals/cases/empty-search.yaml @@ -156,3 +156,5 @@ judge: - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} + - tool_not_called_in_turn: {turn: 2, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/cases/explicit-save.yaml b/evaluation/skills/skill-up/evals/cases/explicit-save.yaml index 3340a96825..459ccb95ff 100644 --- a/evaluation/skills/skill-up/evals/cases/explicit-save.yaml +++ b/evaluation/skills/skill-up/evals/cases/explicit-save.yaml @@ -93,3 +93,4 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__scan_external_skills} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/cases/failed-save.yaml b/evaluation/skills/skill-up/evals/cases/failed-save.yaml index 8e69ad60f7..4c3b9286ed 100644 --- a/evaluation/skills/skill-up/evals/cases/failed-save.yaml +++ b/evaluation/skills/skill-up/evals/cases/failed-save.yaml @@ -109,3 +109,4 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__scan_external_skills} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml b/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml index f419713734..8cac74cf78 100644 --- a/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml +++ b/evaluation/skills/skill-up/evals/cases/inspect-candidates.yaml @@ -96,3 +96,4 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__scan_external_skills} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml b/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml index c577206862..fb19ba13cd 100644 --- a/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml +++ b/evaluation/skills/skill-up/evals/cases/ordinary-coding.yaml @@ -87,3 +87,4 @@ judge: - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__scan_external_skills} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__search_atomic_memory} - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_candidate_history} + - tool_not_called_in_turn: {turn: 1, name: mcp__powercontext__get_recall_manifest} diff --git a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json index 82de60a780..c67e315af5 100644 --- a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json +++ b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-default.json @@ -94,6 +94,9 @@ "get_memory_entry": { "error": "unexpected" }, + "get_recall_manifest": { + "error": "unexpected" + }, "get_scope": { "default": { "scope_id": "skill-up-fixture-scope", diff --git a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json index 611d175d1a..f143e8e26d 100644 --- a/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json +++ b/evaluation/skills/skill-up/evals/fixtures/mcp/powercontext-failed-save.json @@ -94,6 +94,9 @@ "get_memory_entry": { "error": "unexpected" }, + "get_recall_manifest": { + "error": "unexpected" + }, "get_scope": { "default": { "scope_id": "skill-up-fixture-scope", diff --git a/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md b/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md index 6abdcb4edb..23c13fd992 100644 --- a/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md +++ b/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md @@ -130,6 +130,11 @@ not replace those schemas or enforce their constraints. It contains no scenario - Required: `scope_id: string`. - Optional: `citation: object`, `target: LegacyMemoryTarget`. +## mcp__powercontext__get_recall_manifest + +- Required: `scope_id: string`. +- Optional: `cursor: string | null`, `kind: string | null`, `limit: integer = 50`, `tag_match: "all" | "any"`, `tags: array`. + ## mcp__powercontext__get_scope - Required: `scope_id: string`. @@ -238,7 +243,7 @@ not replace those schemas or enforce their constraints. It contains no scenario ## mcp__powercontext__remember_memory - Required: `kind: string`, `scope_id: string`, `text: string`. -- Optional: `expected_revision: integer | null`, `reason: string | null`. +- Optional: `description: string | null`, `expected_revision: integer | null`, `reason: string | null`. ## mcp__powercontext__replace_artifact diff --git a/evaluation/skills/skill-up/tests/test_suite.py b/evaluation/skills/skill-up/tests/test_suite.py index 996c7c1c78..93e6b57454 100644 --- a/evaluation/skills/skill-up/tests/test_suite.py +++ b/evaluation/skills/skill-up/tests/test_suite.py @@ -167,12 +167,20 @@ def test_save_positive_control_rejects_legacy_entry_response(self): validate_suite.validate(self.project) def test_mock_cannot_omit_a_current_atomic_tool(self): - reference = "evals/fixtures/mcp/powercontext-default.json" - fixture = self.read(reference) - fixture["tool_responses"].pop("list_atomic_memories") - self.write(reference, fixture) - with self.assertRaisesRegex(ValueError, "current MCP tools"): - validate_suite.validate(self.project) + for name, message in ( + ("powercontext-default.json", "current MCP tools"), + ("powercontext-failed-save.json", "complete tool catalog"), + ): + reference = "evals/fixtures/mcp/" + name + original = self.read(reference) + for tool in ("list_atomic_memories", "get_recall_manifest"): + with self.subTest(fixture=name, missing=tool): + fixture = json.loads(json.dumps(original)) + fixture["tool_responses"].pop(tool) + self.write(reference, fixture) + with self.assertRaisesRegex(ValueError, message): + validate_suite.validate(self.project) + self.write(reference, original) def test_skill_copy_drift_fails_before_model_execution(self): skill = self.project / "vendor/powercontext-project-context/SKILL.md" diff --git a/evaluation/uv.lock b/evaluation/uv.lock index 35981e0673..33357bdd95 100644 --- a/evaluation/uv.lock +++ b/evaluation/uv.lock @@ -1861,6 +1861,30 @@ notebooks = [ { name = "powercontext-langgraph", editable = "../integrations/langgraph" }, { name = "python-dotenv", specifier = ">=1,<2" }, ] +resource-build = [ + { name = "aiosqlite", specifier = ">=0.22,<1" }, + { name = "alembic", specifier = ">=1.16,<2" }, + { name = "apscheduler", specifier = ">=3.11,<4" }, + { name = "click", specifier = ">=8.1,<9" }, + { name = "filelock", specifier = ">=3.16,<4" }, + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "jsonschema", specifier = ">=4.23,<5" }, + { name = "opentelemetry-api", specifier = ">=1.30,<2" }, + { name = "opentelemetry-sdk", specifier = ">=1.30,<2" }, + { name = "packaging", specifier = ">=24,<27" }, + { name = "platformdirs", specifier = ">=4,<5" }, + { name = "pydantic-settings", specifier = ">=2.7,<3" }, + { name = "pyobvector", specifier = ">=0.2.28,<0.3" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pyyaml", specifier = ">=6,<7" }, + { name = "rich", specifier = ">=13,<16" }, + { name = "ruff", specifier = ">=0.15.7" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2,<3" }, + { name = "sqlite-vec", specifier = ">=0.1.9,<0.2" }, + { name = "ty", specifier = ">=0.0.24" }, + { name = "tzdata", specifier = ">=2026.3" }, + { name = "zstandard", specifier = ">=0.23,<1" }, +] [[package]] name = "powercontext-eval" diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index 3cfa4f7fbe..16333c6eb6 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -71,6 +71,9 @@ capabilities = ["memory_read"] id = "list_atomic_memories" capabilities = ["memory_read"] [[toolsets.tools]] +id = "get_recall_manifest" +capabilities = ["memory_read"] +[[toolsets.tools]] id = "search_atomic_memory" capabilities = ["memory_read"] [[toolsets.tools]] @@ -283,6 +286,9 @@ capabilities = ["memory_read"] id = "mcp__powercontext__list_atomic_memories" capabilities = ["memory_read"] [[toolsets.tools]] +id = "mcp__powercontext__get_recall_manifest" +capabilities = ["memory_read"] +[[toolsets.tools]] id = "mcp__powercontext__search_atomic_memory" capabilities = ["memory_read"] [[toolsets.tools]] @@ -1091,6 +1097,9 @@ tests = ["integrations/dify/tests/test_plugin_contract.py", "tests/e2e/test_dify id = "kimi-code-mcp" probe = "kimi_code_mcp" [[toolsets.tools]] +id = "get_recall_manifest" +capabilities = ["memory_read"] +[[toolsets.tools]] id = "search_memory" capabilities = ["memory_read"] [[toolsets.tools]] diff --git a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs index 5278008abb..dd9f77e131 100644 --- a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs +++ b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs @@ -92,6 +92,17 @@ var ServerResponseError = class extends ClientError { //#endregion //#region src/operations.generated.ts const OPERATIONS = { + get_recall_manifest: { + method: "POST", + path: "/v1/recall/manifest", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_atomic_memories: { method: "POST", path: "/v1/atomic-memory/list", diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index 7163dee793..32a0524cd1 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -75,6 +75,7 @@ "get_atomic_memory_state", "get_experience", "get_memory_entry", + "get_recall_manifest", "get_skill", "get_skill_package_manifest", "get_topic_memory", diff --git a/integrations/dify/generate_contract.py b/integrations/dify/generate_contract.py index 3cd00af465..1fae950a0a 100644 --- a/integrations/dify/generate_contract.py +++ b/integrations/dify/generate_contract.py @@ -41,6 +41,11 @@ "if_match": ("当前内容 ETag", "Real content ETag returned by pc_memory_get for this exact current revision."), "kind": ("记忆类型", "One of the six supported curated Memory kinds."), "text": ("记忆正文", "Curated Memory text: nonblank after NFC/trim and at most 8192 UTF-8 bytes."), + "description": ( + "记忆简介", + "Optional single-line Memory description of at most 200 characters. " + "When omitted, the Server stores a bounded text preview without calling a generation model.", + ), "reason": ("操作原因", "Reason for this explicit operation; preserve the user's intent."), "source_id": ("稳定 Source ID", "Stable caller-selected Source ID for explicitly retained evidence."), "content": ( diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 2291f8fcfa..d247ce4f42 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -549,7 +549,8 @@ ], "get_atomic_memory_state": [], "remember_memory": [ - "reason" + "reason", + "description" ], "revise_memory_entry": [ "artifact" @@ -1150,6 +1151,48 @@ } } }, + "AtomicMemoryReadContent": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "text" + ], + "properties": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/AtomicMemoryContentSchema" + } + ], + "default": "powercontext.atomic-memory.v1" + }, + "kind": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit." + }, + "description": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + "description": "A concise single-line summary of final text, in the same language. Required for new writes." + }, + { + "type": "null" + } + ] + } + } + }, "AtomicMemoryRecord": { "type": "object", "additionalProperties": false, @@ -1294,6 +1337,20 @@ "type": "string", "minLength": 1, "description": "Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit." + }, + "description": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + "description": "Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model." + }, + { + "type": "null" + } + ] } } }, @@ -1368,7 +1425,7 @@ "$ref": "#/components/schemas/ProfileCandidateProposal" }, { - "$ref": "#/components/schemas/AtomicMemoryWriteContent" + "$ref": "#/components/schemas/AtomicMemoryReadContent" }, { "$ref": "#/components/schemas/TopicMemoryDreamProposal" @@ -3631,6 +3688,20 @@ "type": "null" } ] + }, + "description": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + "description": "Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model." + }, + { + "type": "null" + } + ] } } }, diff --git a/integrations/dify/plugin/tools/pc_experience_generate.yaml b/integrations/dify/plugin/tools/pc_experience_generate.yaml index 0f9ffd9b5c..cecacc3c8f 100644 --- a/integrations/dify/plugin/tools/pc_experience_generate.yaml +++ b/integrations/dify/plugin/tools/pc_experience_generate.yaml @@ -666,6 +666,15 @@ output_schema: minLength: 1 description: Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit. + description: + anyOf: + - type: string + minLength: 1 + maxLength: 200 + pattern: ^[^\r\n\u0085\u2028\u2029]*\S[^\r\n\u0085\u2028\u2029]*$ + description: A concise single-line summary of final text, + in the same language. Required for new writes. + - type: 'null' - type: object additionalProperties: false required: diff --git a/integrations/dify/plugin/tools/pc_remember.yaml b/integrations/dify/plugin/tools/pc_remember.yaml index 0d1c949a4c..81cd4bfb88 100644 --- a/integrations/dify/plugin/tools/pc_remember.yaml +++ b/integrations/dify/plugin/tools/pc_remember.yaml @@ -91,6 +91,28 @@ parameters: Pass one JSON-encoded value as text, never a native object/array/null. Encode strings with JSON quotes; use the text null for null; omit optional inputs when unused. Decode exactly once and preserve every field. Decoded JSON Schema: {"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]}' +- name: description + type: string + required: false + form: llm + label: + en_US: description + zh_Hans: 记忆简介 + human_description: + en_US: Optional single-line Memory description of at most 200 characters. When + omitted, the Server stores a bounded text preview without calling a generation + model. Supply one JSON-encoded text value. Strings include JSON quotes; null + is the text null. Omit unused optional inputs and preserve every field. + zh_Hans: 记忆简介;填写一次 JSON 序列化后的文本;字符串须带 JSON 双引号,空值填 null;可选参数不用时省略,保留所有字段。 + llm_description: 'Optional single-line Memory description of at most 200 characters. + When omitted, the Server stores a bounded text preview without calling a generation + model. Pass one JSON-encoded value as text, never a native object/array/null. + Encode strings with JSON quotes; use the text null for null; omit optional inputs + when unused. Decode exactly once and preserve every field. Decoded JSON Schema: + {"anyOf":[{"type":"string","minLength":1,"maxLength":200,"pattern":"^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$","description":"Optional + supplied single-line summary. When omitted, the Server stores a whitespace-collapsed + text prefix of at most 200 Unicode characters, including a trailing ellipsis when + truncated. Explicit writes do not invoke a generation model."},{"type":"null"}]}' output_schema: type: object properties: diff --git a/integrations/dify/plugin/tools/pc_review_get.yaml b/integrations/dify/plugin/tools/pc_review_get.yaml index dc184c4f6e..7996bb95cf 100644 --- a/integrations/dify/plugin/tools/pc_review_get.yaml +++ b/integrations/dify/plugin/tools/pc_review_get.yaml @@ -574,6 +574,15 @@ output_schema: minLength: 1 description: Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit. + description: + anyOf: + - type: string + minLength: 1 + maxLength: 200 + pattern: ^[^\r\n\u0085\u2028\u2029]*\S[^\r\n\u0085\u2028\u2029]*$ + description: A concise single-line summary of final text, in the + same language. Required for new writes. + - type: 'null' - type: object additionalProperties: false required: diff --git a/integrations/dify/plugin/tools/pc_review_list.yaml b/integrations/dify/plugin/tools/pc_review_list.yaml index fdef1aff0a..e138a2ff58 100644 --- a/integrations/dify/plugin/tools/pc_review_list.yaml +++ b/integrations/dify/plugin/tools/pc_review_list.yaml @@ -681,6 +681,15 @@ output_schema: minLength: 1 description: Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit. + description: + anyOf: + - type: string + minLength: 1 + maxLength: 200 + pattern: ^[^\r\n\u0085\u2028\u2029]*\S[^\r\n\u0085\u2028\u2029]*$ + description: A concise single-line summary of final text, + in the same language. Required for new writes. + - type: 'null' - type: object additionalProperties: false required: diff --git a/integrations/dify/plugin/tools/pc_skill_generate.yaml b/integrations/dify/plugin/tools/pc_skill_generate.yaml index d4755099ce..3c6036bc7d 100644 --- a/integrations/dify/plugin/tools/pc_skill_generate.yaml +++ b/integrations/dify/plugin/tools/pc_skill_generate.yaml @@ -691,6 +691,15 @@ output_schema: minLength: 1 description: Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit. + description: + anyOf: + - type: string + minLength: 1 + maxLength: 200 + pattern: ^[^\r\n\u0085\u2028\u2029]*\S[^\r\n\u0085\u2028\u2029]*$ + description: A concise single-line summary of final text, + in the same language. Required for new writes. + - type: 'null' - type: object additionalProperties: false required: diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index acd1dfd177..5c1eaf194a 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -161,6 +161,17 @@ function writeFailureConfirmation(error) { //#endregion //#region src/operations.generated.ts const OPERATIONS = { + get_recall_manifest: { + method: "POST", + path: "/v1/recall/manifest", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_atomic_memories: { method: "POST", path: "/v1/atomic-memory/list", @@ -3394,6 +3405,7 @@ const MCP_OPERATIONS = { "get_experience": { "readOnly": true }, "get_handoff_report": { "readOnly": true }, "get_memory_entry": { "readOnly": true }, + "get_recall_manifest": { "readOnly": true }, "get_scope": { "readOnly": true }, "get_skill": { "readOnly": true }, "get_topic_memory": { "readOnly": true }, diff --git a/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts b/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts index 7a29a5f8ff..e17c46b64b 100644 --- a/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts @@ -89,6 +89,9 @@ export const MCP_OPERATIONS = { "get_memory_entry": { "readOnly": true }, + "get_recall_manifest": { + "readOnly": true + }, "get_scope": { "readOnly": true }, diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..0e843a3a33 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -17,6 +17,7 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { + get_recall_manifest: { method: 'POST', path: '/v1/recall/manifest', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/merges', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/fixture.mjs b/integrations/dsh/plugins/powercontext/tests/runtime/fixture.mjs index a54f32b1b5..4a3d2c796a 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/fixture.mjs +++ b/integrations/dsh/plugins/powercontext/tests/runtime/fixture.mjs @@ -31,6 +31,7 @@ const sdkRequire = createRequire(process.env.DSH_TEST_SDK_ROOT : import.meta.resolve('@deepseek-ai/dsh-sdk-client')) export const dshBin = join(dirname(sdkRequire.resolve('@deepseek-ai/dsh/package.json')), 'lib/bin.js') export const CANARY = 'The aurora deployment color is violet-cedar-1457.' +export const CANARY_DESCRIPTION = 'Aurora deployment color: violet-cedar-1457.' export const NATIVE_ERROR_CANARY = 'private-response-marker: api_key=FAKE_NATIVE_ERROR_CANARY' export const pluginRoot = resolve(import.meta.dirname, '../..') @@ -151,7 +152,9 @@ export async function environment({ realModel, fullCatalog = false } = {}) { action: duplicate ? 'noop' : 'create', compared_ids: input.related.map(item => item.item_id), ...(duplicate ? { target_ids: [duplicate.item_id] } : { - content: { kind: input.proposal.kind, text: input.proposal.text }, + content: { + kind: input.proposal.kind, text: input.proposal.text, description: input.proposal.description, + }, evidence_ids: input.proposal.evidence_ids, }), reason: 'Preserve the runtime fixture fact without duplicating it.', @@ -159,7 +162,7 @@ export async function environment({ realModel, fullCatalog = false } = {}) { } else { const evidence = input.evidence.find(item => JSON.stringify(item).includes(CANARY)) const candidates = evidence ? [{ - kind: 'decision', text: CANARY, evidence_ids: [evidence.evidence_id], + kind: 'decision', text: CANARY, description: CANARY_DESCRIPTION, evidence_ids: [evidence.evidence_id], }] : [] content = JSON.stringify({ candidates }) } diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs b/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs index 93632f6d42..98c990055b 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs +++ b/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs @@ -21,7 +21,7 @@ import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { setTimeout } from 'node:timers/promises' import { test } from 'node:test' -import { environment, injected, CANARY, NATIVE_ERROR_CANARY } from './fixture.mjs' +import { environment, injected, CANARY, CANARY_DESCRIPTION, NATIVE_ERROR_CANARY } from './fixture.mjs' import { installIntoHome } from './setup-fixture.mjs' import { registerSkill } from '../../src/skill.ts' import { MCP_OPERATIONS } from '../../src/mcp-operations.generated.ts' @@ -79,6 +79,8 @@ test('documented setup installs the matched plugin, diagnoses the running host a assert.ok(env.calls.some(call => call.path === '/v1/memory/flush' && call.status === 200), JSON.stringify(env.calls)) const memory = await env.api('/v1/memory/entries/list', { scope_id: env.scopeId }) assert.ok(JSON.stringify(memory).includes(CANARY)) + const manifest = await env.api('/v1/recall/manifest', { scope_id: env.scopeId }) + assert.deepEqual(manifest.items.map(item => item.description), [CANARY_DESCRIPTION]) const diagnosedAt = env.calls.length const firstStatus = await status(first.sessionId) assert.equal(firstStatus.freshness, 'current') diff --git a/integrations/kimi-code/plugins/powercontext/profiles.json b/integrations/kimi-code/plugins/powercontext/profiles.json index 3b65f11715..5e35aee5be 100644 --- a/integrations/kimi-code/plugins/powercontext/profiles.json +++ b/integrations/kimi-code/plugins/powercontext/profiles.json @@ -20,6 +20,7 @@ "get_artifact_revision", "get_atomic_memory_state", "get_memory_entry", + "get_recall_manifest", "get_topic_memory", "handoff_current_work", "list_atomic_memories", @@ -55,6 +56,7 @@ "get_candidate_history", "get_experience", "get_memory_entry", + "get_recall_manifest", "get_skill", "get_topic_memory", "handoff_current_work", diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..0e843a3a33 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -17,6 +17,7 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { + get_recall_manifest: { method: 'POST', path: '/v1/recall/manifest', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/merges', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/opendal/tests/test_opendal_connector.py b/integrations/opendal/tests/test_opendal_connector.py index 88b83e62b2..8af086fc4c 100644 --- a/integrations/opendal/tests/test_opendal_connector.py +++ b/integrations/opendal/tests/test_opendal_connector.py @@ -104,6 +104,7 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR output=AtomicMemoryExtractionOutput( candidates=tuple( AtomicMemoryCandidate( + description="Retained remote source fact", kind="decision", text=TextEvidence.model_validate(evidence.content).content, evidence_ids=(evidence.evidence_id,), @@ -122,7 +123,9 @@ async def generate( output=AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind=request.proposal.kind, text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Retained remote source fact" + ), evidence_ids=request.proposal.evidence_ids, reason="Preserve each independent file snapshot with its exact Source evidence.", ) diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 4d3805f2e8..0e843a3a33 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -17,6 +17,7 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { + get_recall_manifest: { method: 'POST', path: '/v1/recall/manifest', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_atomic_memory: { method: 'POST', path: '/v1/atomic-memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_atomic_memories: { method: 'POST', path: '/v1/atomic-memory/merges', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index f08902602e..8487b03ea4 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -25,6 +25,51 @@ security: - BearerAuth: [] - {} paths: + /v1/recall/manifest: + post: + tags: + - atomic-memory + summary: Read a bounded metadata-only recall manifest + operationId: get_recall_manifest + x-powercontext-scope-mode: current + x-powercontext-access: + action: scope.read + resource: + type: scope + scope-id-from: scope_id + responses: + '200': + description: Operation completed. + content: + application/json: + schema: + $ref: '#/components/schemas/RecallManifestResponse' + '400': + $ref: '#/components/responses/InvalidRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/Unavailable' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RecallManifestRequest' + description: Lists active Atomic Memory metadata only. Empty inventory never proves + the Scope has no context or authorizes skipping prepare_context. Fetch selected + exact refs with get_artifact_revision. Pagination is live keyset order, not + a cross-request snapshot. /v1/atomic-memory/list: post: tags: @@ -5277,6 +5322,83 @@ components: description: Stable feature groups keyed by contract name. additionalProperties: $ref: "#/components/schemas/FeatureContract" + RecallManifestRequest: + type: object + additionalProperties: false + required: + - scope_id + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: .*\S.* + kind: + type: string + minLength: 1 + nullable: true + tags: + type: array + items: + type: string + minLength: 1 + maxItems: 16 + tag_match: + $ref: '#/components/schemas/TagMatch' + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + cursor: + type: string + nullable: true + maxLength: 4096 + RecallManifestEntry: + type: object + additionalProperties: false + required: + - ref + - kind + - description + - state_version + properties: + ref: + $ref: '#/components/schemas/ArtifactReference' + kind: + type: string + description: + type: string + minLength: 1 + maxLength: 200 + pattern: ^[^\r\n\u0085\u2028\u2029]*\S[^\r\n\u0085\u2028\u2029]*$ + description: Stored summary, or null for historical records awaiting explicit + summary completion. No body is read to synthesize it. + nullable: true + state_version: + type: integer + minimum: 0 + RecallManifestResponse: + type: object + additionalProperties: false + required: + - items + - next_cursor + - code + properties: + items: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/RecallManifestEntry' + next_cursor: + type: string + nullable: true + code: + type: string + enum: + - page + - empty_inventory AtomicMemoryState: type: string enum: @@ -5284,6 +5406,33 @@ components: - forgotten - merged - retired + AtomicMemoryReadContent: + type: object + additionalProperties: false + required: + - kind + - text + properties: + schema: + $ref: '#/components/schemas/AtomicMemoryContentSchema' + default: powercontext.atomic-memory.v1 + kind: + type: string + minLength: 1 + maxLength: 128 + text: + type: string + minLength: 1 + description: Memory text is NFC-normalized and trimmed before enforcing an 8192 + UTF-8 byte limit. + description: + type: string + minLength: 1 + maxLength: 200 + pattern: ^[^\r\n\u0085\u2028\u2029]*\S[^\r\n\u0085\u2028\u2029]*$ + description: A concise single-line summary of final text, in the same language. + Required for new writes. + nullable: true AtomicMemoryWriteContent: type: object additionalProperties: false @@ -5301,7 +5450,17 @@ components: text: type: string minLength: 1 - description: Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit. + description: Memory text is NFC-normalized and trimmed before enforcing an 8192 + UTF-8 byte limit. + description: + type: string + minLength: 1 + maxLength: 200 + pattern: ^[^\r\n\u0085\u2028\u2029]*\S[^\r\n\u0085\u2028\u2029]*$ + description: Optional supplied single-line summary. When omitted, the Server stores + a whitespace-collapsed text prefix of at most 200 Unicode characters, including + a trailing ellipsis when truncated. Explicit writes do not invoke a generation model. + nullable: true CreateAtomicMemoryArtifactRequest: type: object additionalProperties: false @@ -6953,7 +7112,7 @@ components: - $ref: '#/components/schemas/ExperienceProposal' - $ref: '#/components/schemas/SkillProposal' - $ref: '#/components/schemas/ProfileCandidateProposal' - - $ref: '#/components/schemas/AtomicMemoryWriteContent' + - $ref: '#/components/schemas/AtomicMemoryReadContent' - $ref: '#/components/schemas/TopicMemoryDreamProposal' - $ref: '#/components/schemas/HandoffContent' - $ref: '#/components/schemas/PromptContent' @@ -10885,13 +11044,16 @@ components: RememberMemoryRequest: type: object additionalProperties: false - required: [scope_id, kind, text] + required: + - scope_id + - kind + - text properties: scope_id: type: string minLength: 1 maxLength: 256 - pattern: '.*\S.*' + pattern: .*\S.* kind: type: string minLength: 1 @@ -10908,6 +11070,15 @@ components: type: integer minimum: 1 nullable: true + description: + type: string + minLength: 1 + maxLength: 200 + pattern: ^[^\r\n\u0085\u2028\u2029]*\S[^\r\n\u0085\u2028\u2029]*$ + description: Optional supplied single-line summary. When omitted, the Server stores + a whitespace-collapsed text prefix of at most 200 Unicode characters, including + a trailing ellipsis when truncated. Explicit writes do not invoke a generation model. + nullable: true RetireMemoryEntryRequest: type: object additionalProperties: false diff --git a/release/upgrade-support.json b/release/upgrade-support.json index b615d5fa05..ad02d6ff52 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "8547b3c6a66c4f31d81730c09cf0a37c1a588030a2571676f7d4788e16a8964b", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged." + "persistence_sha256": "0ae4020d8fa82ad105a93fb014bd1afd19df5a9eb25bb73aa8c87521dbe71ea2", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py index 813fcc8901..bdfd14c54e 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py @@ -23,6 +23,7 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemoryContent +from powercontext.builtin.artifacts.atomic_memory.summary import DESCRIPTION_RULES from powercontext.builtin.artifacts.memory.prompts import MemoryExtractionProfile from powercontext.builtin.inference import InvalidInferenceOutputError, StructuredGenerator, TokenEstimator from powercontext.builtin.persistence.records import _canonical_source_text @@ -68,7 +69,7 @@ def atomic_memory_extraction_instructions_version(profile: MemoryExtractionProfile) -> str: """Return the stable built-in instruction identity for one extraction profile.""" - return f"powercontext.atomic-memory.extract.{profile.value}.v2" + return f"powercontext.atomic-memory.extract.{profile.value}.v3" def atomic_memory_extraction_instructions(profile: MemoryExtractionProfile) -> str: @@ -94,7 +95,9 @@ def atomic_memory_extraction_instructions(profile: MemoryExtractionProfile) -> s proposition. Experience or Skill routing is outside this output contract. Preserve literal commands, code, paths, configuration keys and quoted text exactly when they are themselves factual values. -Each candidate must cite one or more supplied evidence_ids and contain self-contained kind/text. +Each candidate must cite one or more supplied evidence_ids and contain kind, text and description. +The text must preserve the complete atomic fact. +{DESCRIPTION_RULES} Use kind fact, preference, decision, constraint or working_note. Split facts that can change independently. Preserve the conditions, effective/event times and uncertainty in text. Do not allocate Artifact IDs or revisions, choose existing targets, approve or publish anything. Return empty candidates when nothing is worth remembering. @@ -124,6 +127,7 @@ class AtomicMemoryCandidate(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) kind: Literal["fact", "preference", "decision", "constraint", "working_note"] text: str + description: str = Field(min_length=1, max_length=200) evidence_ids: tuple[str, ...] = Field(min_length=1) @@ -157,7 +161,11 @@ async def extract(self, value: AtomicMemoryExtractionInput) -> tuple[AtomicMemor supplied = {item.evidence_id for item in value.evidence} for candidate in output.candidates: try: - AtomicMemoryContent(kind=candidate.kind, text=candidate.text) + AtomicMemoryContent( + kind=candidate.kind, + text=candidate.text, + description=candidate.description, + ) except ValidationError as error: raise attach_diagnostics( InvalidInferenceOutputError("atomic-memory-extract", "candidate content is invalid"), diff --git a/src/powercontext/builtin/artifacts/atomic_memory/models.py b/src/powercontext/builtin/artifacts/atomic_memory/models.py index 74fa76bdb9..8b6971a4d4 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/models.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/models.py @@ -27,6 +27,23 @@ from powercontext.builtin.artifacts.memory.canonical import canonical_error_code, normalize_text +class RecallManifestEntry(BaseModel): + """Metadata for one exact active Atomic Memory revision; no body or embedding.""" + + ref: ArtifactRef + kind: str + description: str | None + state_version: int + + +class RecallManifestPage(BaseModel): + """A bounded live keyset window, never proof that other context families are empty.""" + + items: tuple[RecallManifestEntry, ...] + next_cursor: str | None + code: Literal["page", "empty_inventory"] + + class AtomicMemoryCreation(BaseModel): """Select merge inputs from the exact lineage of revision one.""" @@ -51,6 +68,8 @@ class AtomicMemoryContent(BaseModel): schema_: Literal["powercontext.atomic-memory.v1"] = Field(default="powercontext.atomic-memory.v1", alias="schema") kind: str = Field(min_length=1, max_length=128) text: str = Field(min_length=1) + # Historical revisions predate summaries; omit the absent field to preserve their serialized content. + description: str | None = Field(default=None, min_length=1, max_length=200, exclude_if=lambda value: value is None) creation: AtomicMemoryCreation | None = None @field_validator("kind") @@ -74,6 +93,19 @@ def valid_text(cls, value: str) -> str: # the owning service normalizes new writes before preparing a draft. return value + @field_validator("description") + @classmethod + def valid_description(cls, value: str | None) -> str | None: + if value is None: + return value + if value is None: + return value + if not value.strip(): + raise ValueError("description must not be blank") # noqa: TRY003 + if any(char in value for char in ("\n", "\r", "\u0085", "\u2028", "\u2029")): + raise ValueError("description must be a single line") # noqa: TRY003 + return value + def without_creation(self) -> AtomicMemoryContent: return self.model_copy(update={"creation": None}) diff --git a/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py b/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py index 2261d08ede..9005258d28 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py @@ -26,6 +26,7 @@ from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError from powercontext.builtin.artifacts.atomic_memory.extraction import LANGUAGE_RULES, TIME_RULES, AtomicMemoryEvidence from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemoryContent, AtomicMemoryRead +from powercontext.builtin.artifacts.atomic_memory.summary import DESCRIPTION_RULES from powercontext.builtin.inference import InvalidInferenceOutputError from powercontext.builtin.processing_diagnostics import ( ProcessingDiagnostics, @@ -66,6 +67,10 @@ different applicability separate. Keep each identity's final decision consistent with previously combined working content. Preserve independent historical events, conditions, exceptions and effective dates. Never request approval or store conflict markers. +For every create, revise or merge action, return content containing kind, text and description. +Apply these rules to the final text: +{DESCRIPTION_RULES} +For noop, keep content null. {LANGUAGE_RULES} Preserve the language of retained, unchanged published facts. Express genuinely new or corrected facts in the language of their supporting Source statements; unrelated new evidence must not cause existing facts to be translated. @@ -78,6 +83,7 @@ class AtomicMemoryComparisonItem(BaseModel): item_id: str kind: str text: str + description: str | None = None original_refs: tuple[AtomicMemoryRead, ...] evidence_ids: tuple[str, ...] @@ -107,6 +113,7 @@ class AtomicMemoryReconciliationContent(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) kind: str = Field(min_length=1, max_length=128) text: str = Field(min_length=1) + description: str = Field(min_length=1, max_length=200) @model_validator(mode="before") @classmethod @@ -118,7 +125,7 @@ def ordinary_content(cls, value: Any) -> Any: content = AtomicMemoryContent.model_validate(value) if content.creation is not None: raise ValueError("creation metadata is constructed only by the merge service") # noqa: TRY003 - return {"kind": content.kind, "text": content.text} + return {"kind": content.kind, "text": content.text, "description": content.description} class AtomicMemoryReconciliationOutput(BaseModel): @@ -157,6 +164,7 @@ def comparison(self) -> AtomicMemoryComparisonItem: item_id=self.key, kind=self.content.kind, text=self.content.text, + description=self.content.description, original_refs=self.origins, evidence_ids=tuple(item.evidence_id for item in self.evidence), ) @@ -229,7 +237,9 @@ def apply( if output.content is None: _invalid("write content is missing") try: - content = AtomicMemoryContent(kind=output.content.kind, text=output.content.text) + content = AtomicMemoryContent( + kind=output.content.kind, text=output.content.text, description=output.content.description + ) except ValidationError as error: raise attach_diagnostics( InvalidInferenceOutputError("atomic-memory-reconcile", "write content is invalid"), diff --git a/src/powercontext/builtin/artifacts/atomic_memory/service.py b/src/powercontext/builtin/artifacts/atomic_memory/service.py index 5c6f1c177a..33f3657012 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/service.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/service.py @@ -18,9 +18,11 @@ from collections.abc import Mapping, Sequence from contextlib import suppress +from dataclasses import replace from typing import TYPE_CHECKING, Any, Protocol, cast from sqlalchemy.ext.asyncio import AsyncConnection +from typing_extensions import override from powercontext.artifacts import ArtifactLineage, ArtifactRef from powercontext.builtin.artifacts.atomic_memory.errors import ( @@ -48,11 +50,13 @@ AtomicMemoryWrite, PreparedAtomicMemory, ) +from powercontext.builtin.artifacts.atomic_memory.summary import default_description from powercontext.builtin.artifacts.memory.canonical import canonical_error_code, normalize_text from powercontext.builtin.artifacts.memory.errors import InvalidMemoryCandidateError from powercontext.builtin.artifacts.merge import ArtifactMergeSecurity, ArtifactMergeService, MergeTags from powercontext.builtin.artifacts.merge_models import ( ArtifactMergeErrors, + ArtifactMergeMutationResult, ArtifactMergePlan, ArtifactMergeRead, ArtifactMergeRecord, @@ -311,6 +315,58 @@ def validate(self, *args, **kwargs): return _common_read(self.signer.validate(*args, **kwargs)) +class _AtomicMemoryMergeService(ArtifactMergeService): + """Apply the same summary invariant to generic and Atomic-specific merge entry points.""" + + @override + async def prepare_change(self, plan: ArtifactMergePlan) -> PreparedArtifactMerge: + # Persist the description with the final normalized body before preparing its projection. + if plan.operation in {"change", "merge"}: + writes = [] + for write in plan.writes: + draft = write.draft + if draft is not None and draft.content.description is None: + content = AtomicMemoryContent.model_validate({ + **draft.content.model_dump(mode="json", by_alias=True), + "description": default_description(draft.content.text), + }) + draft = draft.model_copy(update={"content": content}) + writes.append(replace(write, draft=draft)) + plan = replace(plan, writes=tuple(writes)) + return await super().prepare_change(plan) + + @override + async def commit_window( + self, + connection: AsyncConnection, + scope_id: str, + prepared: Sequence[PreparedArtifactMerge], + context: Any, + *, + read_set: Sequence[ArtifactMergeRead] = (), + direct_source: SourceRef | None = None, + direct_sources: Sequence[SourceRef | None] | None = None, + source_read_set: _SourceReadSet | None = None, + ) -> tuple[ArtifactMergeMutationResult, ...]: + # Prepared content and projection hashes are already fixed. Reject malformed + # prepared writes here rather than applying defaults after preparation. + for item in prepared: + if item.plan.operation in {"change", "merge"}: + for write in item.plan.writes: + if write.draft is not None and write.draft.content.description is None: + raise InvalidMemoryCandidateError("canonical", "description is required for new writes") + return await super().commit_window( + connection, + scope_id, + prepared, + context, + read_set=read_set, + direct_source=direct_source, + direct_sources=direct_sources, + source_read_set=source_read_set, + ) + + class AtomicMemoryService: """Keep Atomic public contracts while delegating all transitions to the common service.""" @@ -326,7 +382,7 @@ def __init__( ) -> None: self.artifacts, self.states, self.security = artifacts, states, security self.projections, self.merge_tags, self.preview_signer = projections, merge_tags, preview_signer - self.shared = ArtifactMergeService( + self.shared = _AtomicMemoryMergeService( artifacts=artifacts, adapter=AtomicMemoryMergeAdapter(projections), security=security, @@ -525,7 +581,7 @@ def restoration_preview(self, plan: AtomicMemoryPlan) -> AtomicMemoryRestoration async def prepare_change(self, plan: AtomicMemoryPlan) -> PreparedAtomicMemory: prepared = await self.shared.prepare_change(_common_plan(plan)) - return PreparedAtomicMemory(plan=plan, projections=prepared.projections) + return PreparedAtomicMemory(plan=_atomic_plan(prepared.plan), projections=prepared.projections) prepare_merge = prepare_change prepare_forget = prepare_change diff --git a/src/powercontext/builtin/artifacts/atomic_memory/summary.py b/src/powercontext/builtin/artifacts/atomic_memory/summary.py new file mode 100644 index 0000000000..651867c1f2 --- /dev/null +++ b/src/powercontext/builtin/artifacts/atomic_memory/summary.py @@ -0,0 +1,33 @@ +# 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. + +"""Catalog descriptions for generated and explicit Atomic Memory writes.""" + + +def default_description(text: str) -> str: + """Use a bounded single-line prefix for explicit writes without a supplied label.""" + line = " ".join(text.split()) + return line if len(line) <= 200 else line[:199].rstrip() + "…" + + +DESCRIPTION_RULES = """ +Write description as a short, informative catalog label that helps an agent decide whether to read the full text. +Name the specific subject and topic, rather than restating the complete fact or merely changing its punctuation. +Prefer a compact phrase, usually within 60 characters; the hard limit is 200 characters and one nonblank line. +Use the same natural language as the text. Leave detailed values, lists and explanations in text unless essential +for distinguishing this memory from other topics. Keep qualifiers needed to avoid a misleading label; do not add facts. +For example, text "登录采用 JWT; 访问令牌有效期为 30 分钟; 刷新令牌有效期为 7 天。" can have +description "JWT 登录方案与令牌有效期", rather than repeating both durations. +For an already minimal fact, a similar description is acceptable; do not make the label vague merely to shorten it. +""".strip() diff --git a/src/powercontext/builtin/artifacts/prompt/builtin.py b/src/powercontext/builtin/artifacts/prompt/builtin.py index d9c8d7efb6..9fd1f774eb 100644 --- a/src/powercontext/builtin/artifacts/prompt/builtin.py +++ b/src/powercontext/builtin/artifacts/prompt/builtin.py @@ -105,7 +105,7 @@ def builtin_prompt_definitions( return ( PromptDefinition( key="atomic_memory.extract", - definition_version="powercontext.prompt.atomic-memory.extract.v1", + definition_version="powercontext.prompt.atomic-memory.extract.v2", input_type=AtomicMemoryExtractionInput, output_type=AtomicMemoryExtractionOutput, builtin_version=atomic_memory_extraction_instructions_version(profile), @@ -116,10 +116,10 @@ def builtin_prompt_definitions( ), PromptDefinition( key="atomic_memory.reconcile", - definition_version="powercontext.prompt.atomic-memory.reconcile.v1", + definition_version="powercontext.prompt.atomic-memory.reconcile.v2", input_type=AtomicMemoryReconciliationInput, output_type=AtomicMemoryReconciliationOutput, - builtin_version="powercontext.atomic-memory.reconcile.v1", + builtin_version="powercontext.atomic-memory.reconcile.v2", invariant_instructions=ATOMIC_MEMORY_RECONCILIATION_INSTRUCTIONS, default_instructions=ATOMIC_MEMORY_RECONCILIATION_INSTRUCTIONS, ), diff --git a/src/powercontext/builtin/artifacts/prompt/validation.py b/src/powercontext/builtin/artifacts/prompt/validation.py index 2973b8a8c9..5e531c6ad0 100644 --- a/src/powercontext/builtin/artifacts/prompt/validation.py +++ b/src/powercontext/builtin/artifacts/prompt/validation.py @@ -91,7 +91,11 @@ def validate_demonstration(value: BaseModel, output: BaseModel) -> None: # noqa if isinstance(value, AtomicMemoryExtractionInput) and isinstance(output, AtomicMemoryExtractionOutput): evidence = _identities(item.evidence_id for item in value.evidence) for candidate in output.candidates: - AtomicMemoryContent(kind=candidate.kind, text=candidate.text) + AtomicMemoryContent( + kind=candidate.kind, + text=candidate.text, + description=candidate.description, + ) _require(bool(candidate.evidence_ids) and set(candidate.evidence_ids) <= evidence) elif isinstance(value, AtomicMemoryReconciliationInput) and isinstance(output, AtomicMemoryReconciliationOutput): validate_reconciliation_output(value, output) diff --git a/src/powercontext/builtin/persistence/atomic_memory_index.py b/src/powercontext/builtin/persistence/atomic_memory_index.py index effe642f6c..d4a7fe4ca8 100644 --- a/src/powercontext/builtin/persistence/atomic_memory_index.py +++ b/src/powercontext/builtin/persistence/atomic_memory_index.py @@ -116,6 +116,7 @@ class PreparedAtomicMemoryProjection: text: str searchable_text: str content_hash: str + description: str | None = None embedding: tuple[float, ...] | None = None embedding_profile: EmbeddingProfile | None = None profile_fingerprint: str | None = None @@ -260,6 +261,7 @@ async def prepare(self, content: BaseModel) -> PreparedAtomicMemoryProjection: return PreparedAtomicMemoryProjection( kind=kind, text=body, + description=payload.get("description"), searchable_text=analyze_text(input_text), content_hash=sha256(canonical_json(payload)).hexdigest(), embedding=vector, @@ -593,6 +595,12 @@ def _encode_embedding(self, vector: tuple[float, ...]) -> object: return vector async def require_current_schema(self, connection: AsyncConnection) -> None: + # Add only the new nullable projection metadata. Never read bodies or call inference at startup. + columns = await _projection_columns(connection, self.table) + if columns and "description" not in columns: + await connection.exec_driver_sql( + "ALTER TABLE pc_atomic_memory_current ADD COLUMN description VARCHAR(200) NULL" + ) if await _projection_columns(connection, self.table) & _OBSOLETE_PROJECTION_COLUMNS: raise AtomicMemoryIndexError( "current-schema", @@ -659,6 +667,7 @@ def _projection_values(self, scope_id: str, projection: AtomicMemoryProjection) "projection_format": ATOMIC_MEMORY_PROJECTION_FORMAT, "kind": prepared.kind, "text": prepared.text, + "description": prepared.description, "searchable_text": prepared.searchable_text, "tag_keys": json.dumps(sorted(set(projection.tag_keys)), ensure_ascii=False), "embedding": embedding, diff --git a/src/powercontext/builtin/persistence/atomic_memory_index_schema.py b/src/powercontext/builtin/persistence/atomic_memory_index_schema.py index 9210fba152..848c572ba8 100644 --- a/src/powercontext/builtin/persistence/atomic_memory_index_schema.py +++ b/src/powercontext/builtin/persistence/atomic_memory_index_schema.py @@ -16,7 +16,7 @@ from typing import Any -from sqlalchemy import BigInteger, CheckConstraint, Column, LargeBinary, MetaData, Table, Text +from sqlalchemy import BigInteger, CheckConstraint, Column, LargeBinary, MetaData, String, Table, Text from sqlalchemy.dialects.mysql import MEDIUMTEXT from sqlalchemy.types import TypeEngine @@ -46,6 +46,7 @@ def atomic_memory_current_table(embedding_type: TypeEngine[Any] | None = None, / Column("projection_format", identity_string(64), nullable=False), Column("kind", body_type, nullable=False), Column("text", body_type, nullable=False), + Column("description", String(200), nullable=True), Column("searchable_text", body_type, nullable=False), Column("tag_keys", body_type, nullable=False), Column("embedding", LargeBinary() if embedding_type is None else embedding_type), diff --git a/src/powercontext/builtin/persistence/migrations/mysql_schema.py b/src/powercontext/builtin/persistence/migrations/mysql_schema.py index 52badbd8a3..4364a4fde9 100644 --- a/src/powercontext/builtin/persistence/migrations/mysql_schema.py +++ b/src/powercontext/builtin/persistence/migrations/mysql_schema.py @@ -309,7 +309,13 @@ def _extend_vectors(self) -> None: for revision in self.revisions: for state in [*self._prefixes[revision], self._schemas[revision]]: if atomic_name in state["tables"]: + columns = state["tables"][atomic_name]["columns"] state["tables"][atomic_name] = copy.deepcopy(atomic) + # Retain columns introduced after the frozen vector profile + # while preserving its released vector and charset metadata. + state["tables"][atomic_name]["columns"].update({ + name: copy.deepcopy(column) for name, column in columns.items() if name not in atomic["columns"] + }) if revision != source: state["tables"].update(copy.deepcopy(tables)) self._statements[source] = tuple( diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 65c518659d..3ffc68c92c 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -24,6 +24,10 @@ "v1_4_1_004": [ "schemas/sqlite-v1_4_1_004.json", "schemas/mysql-v1_4_1_004.json" + ], + "v1_4_1_005": [ + "schemas/sqlite-v1_4_1_005.json", + "schemas/mysql-v1_4_1_005.json" ] }, "sqlite_baselines": { @@ -44,6 +48,9 @@ ], "v1_4_1_004": [ "84d736b5e722a56ebe8a32318732543d43548a63a43709dbf0c571e7e4af83b5" + ], + "v1_4_1_005": [ + "2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4" ] }, "affected_objects": [ @@ -195,6 +202,10 @@ "v1_4_1_004": [ "server_upgrade", "portable_storage" + ], + "v1_4_1_005": [ + "server_upgrade", + "portable_storage" ] }, "retained_fingerprints": { @@ -210,7 +221,8 @@ "schemas/mysql-s1400.json", "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", - "schemas/mysql-v1_4_1_004.json" + "schemas/mysql-v1_4_1_004.json", + "schemas/mysql-v1_4_1_005.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py new file mode 100644 index 0000000000..ded262cbb6 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py @@ -0,0 +1,24 @@ +# 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. + +"""Add Atomic Memory descriptions through the shared DDL executor.""" + +from alembic import context + +revision = "v1_4_1_005" +down_revision = "v1_4_1_004" + + +def upgrade() -> None: + context.config.attributes["execute_revision"](revision) diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json new file mode 100644 index 0000000000..aae1e2028e --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json @@ -0,0 +1,314 @@ +{ + "format": "powercontext.mysql-state-layouts.v1", + "revisions": { + "v1_4_1_005": { + "statements": [ + "ALTER TABLE pc_atomic_memory_current ADD COLUMN description VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL" + ], + "changes": [ + { + "pc_atomic_memory_current": { + "columns": { + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "artifact_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "revision": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "state_version": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "content_hash": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "projection_format": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "kind": { + "type": [ + "mediumtext", + [] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": null, + "extra": [] + }, + "text": { + "type": [ + "mediumtext", + [] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": null, + "extra": [] + }, + "searchable_text": { + "type": [ + "mediumtext", + [] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": null, + "extra": [] + }, + "tag_keys": { + "type": [ + "mediumtext", + [] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": null, + "extra": [] + }, + "embedding": { + "type": [ + "blob", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "profile_fingerprint": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "embedding_input_hash": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "description": { + "type": [ + "varchar", + [ + "200" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + } + }, + "primary_key": [ + [ + "scope_id", + null, + "asc" + ], + [ + "artifact_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_atomic_memory_current_revision": { + "expression": [ + "binary", + ">", + [ + "column", + "revision" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_atomic_memory_current_state_version": { + "expression": [ + "binary", + ">=", + [ + "column", + "state_version" + ], + [ + "number", + 0 + ] + ], + "enforced": true + }, + "ck_pc_atomic_memory_current_embedding_metadata": { + "expression": [ + "binary", + "or", + [ + "binary", + "and", + [ + "binary", + "and", + [ + "is_null", + [ + "column", + "embedding" + ] + ], + [ + "is_null", + [ + "column", + "profile_fingerprint" + ] + ] + ], + [ + "is_null", + [ + "column", + "embedding_input_hash" + ] + ] + ], + [ + "binary", + "and", + [ + "binary", + "and", + [ + "is_not_null", + [ + "column", + "embedding" + ] + ], + [ + "is_not_null", + [ + "column", + "profile_fingerprint" + ] + ] + ], + [ + "is_not_null", + [ + "column", + "embedding_input_hash" + ] + ] + ] + ], + "enforced": true + } + }, + "indexes": { + "ix_pc_atomic_memory_current_fts": { + "columns": [ + [ + "searchable_text", + null, + "asc" + ] + ], + "unique": false, + "kind": "fulltext", + "parser": "space", + "parser_properties": { + "min_token_size": 3, + "max_token_size": 84 + } + } + } + } + } + ] + } + } +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json new file mode 100644 index 0000000000..892c226f35 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json @@ -0,0 +1,3 @@ +{ + "statement": "ALTER TABLE pc_atomic_memory_current ADD COLUMN description VARCHAR(200)" +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_atomic_memory_description.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_atomic_memory_description.py new file mode 100644 index 0000000000..f021e6384d --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_atomic_memory_description.py @@ -0,0 +1,28 @@ +# 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. + +"""Add nullable metadata to the current Atomic Memory projection.""" + +import json +from pathlib import Path + +from alembic import op + +revision = "v1_4_1_005" +down_revision = "v1_4_1_004" + + +def upgrade(): + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_005.json" + op.get_bind().exec_driver_sql(json.loads(resource.read_text())["statement"]) diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index 0e52e9ff79..dea50c81f8 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -23,6 +23,10 @@ "v1_4_1_004": [ "schemas/sqlite-v1_4_1_004.json", "schemas/mysql-v1_4_1_004.json" + ], + "v1_4_1_005": [ + "schemas/sqlite-v1_4_1_005.json", + "schemas/mysql-v1_4_1_005.json" ] }, "sqlite_baselines": { @@ -43,6 +47,9 @@ ], "v1_4_1_004": [ "84d736b5e722a56ebe8a32318732543d43548a63a43709dbf0c571e7e4af83b5" + ], + "v1_4_1_005": [ + "2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4" ] }, "affected_objects": [ @@ -199,6 +206,10 @@ "v1_4_1_004": [ "server_upgrade", "portable_storage" + ], + "v1_4_1_005": [ + "server_upgrade", + "portable_storage" ] }, "retained_fingerprints": { @@ -214,7 +225,8 @@ "schemas/mysql-s1400.json", "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", - "schemas/mysql-v1_4_1_004.json" + "schemas/mysql-v1_4_1_004.json", + "schemas/mysql-v1_4_1_005.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/vector_profile.py b/src/powercontext/builtin/persistence/migrations/vector_profile.py index f2a4d2814d..f96ee16d8f 100644 --- a/src/powercontext/builtin/persistence/migrations/vector_profile.py +++ b/src/powercontext/builtin/persistence/migrations/vector_profile.py @@ -19,6 +19,7 @@ import copy import json import sqlite3 +from contextlib import closing from pathlib import Path from typing import Any @@ -85,6 +86,21 @@ def sqlite_layouts(self, directory: Path) -> dict[str, list[list[Any]]]: layouts["v1_4_1_004"] = layouts["s1402"] + [ ["table", "pc_access_owner_recovery", "pc_access_owner_recovery", owner] ] + if "v1_4_1_005" in manifest["revision_resources"]: + layouts["v1_4_1_005"] = copy.deepcopy(layouts["v1_4_1_004"]) + statement = json.loads((directory / "schemas/sqlite-v1_4_1_005.json").read_text(encoding="utf-8"))[ + "statement" + ] + for row in layouts["v1_4_1_005"]: + if row[0] == "table" and row[1] == "pc_atomic_memory_current": + # SQLite records ALTER TABLE's exact rewritten definition. + # Render it with SQLite while retaining every vector object. + with closing(sqlite3.connect(":memory:")) as connection: + connection.execute(row[3]) + connection.execute(statement) + row[3] = connection.execute( + "SELECT sql FROM sqlite_schema WHERE name='pc_atomic_memory_current'" + ).fetchone()[0] for entries in layouts.values(): entries.sort(key=lambda row: (row[0], row[1])) return layouts diff --git a/src/powercontext/builtin/persistence/topic_memory_recovery_schema.py b/src/powercontext/builtin/persistence/topic_memory_recovery_schema.py index 7d6fb8ac16..3b8b20d721 100644 --- a/src/powercontext/builtin/persistence/topic_memory_recovery_schema.py +++ b/src/powercontext/builtin/persistence/topic_memory_recovery_schema.py @@ -18,6 +18,8 @@ import json import re +import sqlite3 +from contextlib import closing from functools import lru_cache from pathlib import Path from typing import Any @@ -154,6 +156,17 @@ def recovery_inventory(connection: Connection, *, include_leases: bool = False) return result +@lru_cache +def _description_altered_projection_ddl(legacy_ddl: str) -> str: + """Render SQLite's exact ADD COLUMN form from the frozen empty projection DDL.""" + with closing(sqlite3.connect(":memory:")) as connection: + connection.execute(legacy_ddl) + connection.execute("ALTER TABLE pc_atomic_memory_current ADD COLUMN description VARCHAR(200) NULL") + return connection.execute( + "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'pc_atomic_memory_current'" + ).fetchone()[0] + + def _atomic_maintenance_initialization(connection: Connection) -> bool: """Recognize the complete, pristine SQLite output of Atomic maintenance. @@ -172,7 +185,20 @@ def _atomic_maintenance_initialization(connection: Connection) -> bool: ) if row[2] not in ignored ] - if actual != expected["schema"]: + # The manifest adds only nullable metadata to the pristine Atomic projection. + # Recognize that exact additive layout without changing the frozen v1 resource + # or accepting arbitrary columns, FTS helpers, triggers or persisted evidence. + with_description = [list(row) for row in expected["schema"]] + altered_description = [list(row) for row in expected["schema"]] + for row in altered_description: + if row[:2] == ["table", "pc_atomic_memory_current"]: + row[3] = _description_altered_projection_ddl(row[3]) + for row in with_description: + if row[:2] == ["table", "pc_atomic_memory_current"]: + row[3] = row[3].replace( + "\ttext TEXT NOT NULL, \n", "\ttext TEXT NOT NULL, \n\tdescription VARCHAR(200), \n", 1 + ) + if actual not in (expected["schema"], with_description, altered_description): return False return all( connection.exec_driver_sql(f'SELECT 1 FROM "{name}" LIMIT 1').first() is None # noqa: S608 - frozen identifiers diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index ff3503973e..df1e07c2d4 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -740,7 +740,13 @@ async def create_artifact( execution_context: Any = None, on_commit: Callable[[Any, ArtifactCreated], Awaitable[None]] | None = None, ) -> ArtifactCreated: - async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + async with ( + self._runtime._scoped_operation( + self.scope_id, + embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING if family == "atomic-memory" else None, + ), + self._runtime._locked(self.scope_id), + ): return await self._runtime._records().create_artifact( self.scope_id, family, @@ -846,7 +852,13 @@ async def replace_artifact( actor_id: str | None = None, execution_context: Any = None, ) -> ArtifactRecord: - async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + async with ( + self._runtime._scoped_operation( + self.scope_id, + embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING if family == "atomic-memory" else None, + ), + self._runtime._locked(self.scope_id), + ): return await self._runtime._records().replace_artifact( self.scope_id, family, @@ -3385,6 +3397,10 @@ async def _filter_current_candidates(self, candidates, *, context=None): async with self._runtime._scope_operation(self.scope_id): return await self._scoped._filter_current_candidates(candidates, context=context) + async def manifest(self, **kwargs): + async with self._runtime._scope_operation(self.scope_id): + return await self._scoped.manifest(**kwargs) + async def list(self, **kwargs): async with self._runtime._scope_operation(self.scope_id): return await self._scoped.list(**kwargs) @@ -3422,7 +3438,10 @@ async def search(self, query: str, *, _trace: bool = True, **kwargs): async def create(self, contents: Sequence[AtomicMemoryContent], /, *, context=None) -> AtomicMemoryMutationResult: """Create explicit Atomic Memories in one all-or-nothing batch.""" values = tuple(content.model_dump(mode="json", by_alias=True, exclude_none=True) for content in contents) - async with self._runtime._scoped_operation(self.scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING): + async with self._runtime._scoped_operation( + self.scope_id, + embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING, + ): created = await self._runtime._records().create_atomic_memories( self.scope_id, values, execution_context=context ) @@ -3440,7 +3459,10 @@ async def cursor(self) -> SourceCursor: return await runtime_context.triggers.cursor() async def merge(self, inputs, content, **kwargs): - async with self._runtime._scoped_operation(self.scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING): + async with self._runtime._scoped_operation( + self.scope_id, + embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING, + ): return await self._scoped.merge(inputs, content, **kwargs) async def forget(self, artifact_id: str, **kwargs): @@ -3452,7 +3474,10 @@ async def preview_restoration(self, artifact_id: str, **kwargs): return await self._scoped.preview_restoration(artifact_id, **kwargs) async def restore(self, artifact_id: str, **kwargs): - async with self._runtime._scoped_operation(self.scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING): + async with self._runtime._scoped_operation( + self.scope_id, + embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING, + ): return await self._scoped.restore(artifact_id, **kwargs) diff --git a/src/powercontext/builtin/runtime/atomic_memory.py b/src/powercontext/builtin/runtime/atomic_memory.py index b6244b7700..1536b7606a 100644 --- a/src/powercontext/builtin/runtime/atomic_memory.py +++ b/src/powercontext/builtin/runtime/atomic_memory.py @@ -26,7 +26,8 @@ from typing import cast from uuid import uuid4 -from sqlalchemy import Text, and_, func, or_, select +import rfc8785 +from sqlalchemy import LargeBinary, Text, and_, func, literal, or_, select from sqlalchemy import cast as sql_cast from sqlalchemy.ext.asyncio import AsyncConnection from typing_extensions import override @@ -45,6 +46,8 @@ AtomicMemoryRead, AtomicMemoryRecord, AtomicMemoryStateValue, + RecallManifestEntry, + RecallManifestPage, ) from powercontext.builtin.artifacts.atomic_memory.restoration import AtomicMemoryPreviewSigner from powercontext.builtin.artifacts.atomic_memory.search import AtomicArtifactSearchRequest, plan_atomic_search @@ -374,6 +377,104 @@ async def get_many(self, artifact_ids: Sequence[str], *, context=None) -> tuple[ raise RepositoryNotFoundError("artifact", (self.scope_id, "atomic-memory", missing)) return tuple(by_id[artifact_id] for artifact_id in artifact_ids) + async def manifest( + self, + *, + kind: str | None = None, + tag_filter: TagFilter | None = None, + limit: int = 50, + cursor: str | None = None, + context=None, + ) -> RecallManifestPage: + """Read only current metadata; never assemble content or infer summaries.""" + _validate_limit(limit) + if kind is not None: + AtomicMemoryContent(kind=kind, text="validation", description="validation") + # Hash the complete binding so accepted Unicode filters cannot inflate the + # signed cursor beyond the transport's 4096-character request limit. + # Preserve exact strings: the query does not apply Unicode normalization. + bound = { + "endpoint": "recall_manifest", + "version": 3, + "binding_hash": sha256( + rfc8785.dumps({ + "scope_id": self.scope_id, + "subject": self.application.security.subject(context), + "kind": kind, + "tags": None if tag_filter is None else list(tag_filter.keys), + "tag_match": None if tag_filter is None else tag_filter.match, + }) + ).hexdigest(), + } + after = self.application.cursor.after_text(cursor, bound) + async with self._read(context) as connection: + await self.application.security.filters(connection, self.scope_id, context, tags=tag_filter) + current = self.application.index.table + heads = ARTIFACT_HEADS_TABLE + statement = ( + select( + current.c.artifact_id, + current.c.revision, + current.c.state_version, + current.c.kind, + current.c.description, + ) + .select_from( + current.join( + heads, + and_( + current.c.scope_id == heads.c.scope_id, + current.c.artifact_id == heads.c.artifact_id, + current.c.revision == heads.c.revision, + current.c.state_version == heads.c.governance_generation, + heads.c.family == "atomic-memory", + ), + ) + ) + .where( + current.c.scope_id == self.scope_id, + current.c.artifact_id > after, + heads.c.lifecycle_state == "active", + heads.c.merged_into_id.is_(None), + current.c.projection_format == ATOMIC_MEMORY_PROJECTION_FORMAT, + ) + .order_by(current.c.artifact_id) + .limit(limit + 1) + ) + if kind is not None: + if connection.dialect.name == "mysql": + # Binary strings preserve trailing spaces as well as case. + predicate = sql_cast(current.c.kind, LargeBinary) == sql_cast(literal(kind), LargeBinary) + else: + predicate = current.c.kind.collate("BINARY") == kind + statement = statement.where(predicate) + if tag_filter is not None: + statement = statement.where( + tag_predicate( + self.scope_id, + "atomic-memory", + current.c.artifact_id, + "artifact", + current.c.artifact_id, + tag_filter, + ) + ) + rows = (await connection.execute(statement)).mappings().all() + items = tuple( + RecallManifestEntry( + ref=ArtifactRef(family="atomic-memory", artifact_id=row["artifact_id"], revision=row["revision"]), + kind=row["kind"], + description=row["description"], + state_version=row["state_version"], + ) + for row in rows[:limit] + ) + return RecallManifestPage( + items=items, + code="page" if items else "empty_inventory", + next_cursor=self.application.cursor.encode(bound, items[-1].ref.artifact_id) if len(rows) > limit else None, + ) + def list_query( self, connection: AsyncConnection, @@ -391,7 +492,11 @@ def list_query( raise InvalidBaseAccessRequestError("states", "must contain distinct lifecycle values") states = tuple(AtomicMemoryStateValue(state).value for state in states) if kind is not None: - AtomicMemoryContent(kind=kind, text="validation") + AtomicMemoryContent( + kind=kind, + text="validation", + description="validation", + ) bound = { "endpoint": "atomic_memory_list", "version": 1, @@ -542,7 +647,11 @@ async def search( # noqa: C901 if mode not in {"auto", "text", "vector", "hybrid"}: raise InvalidBaseAccessRequestError("mode", "must be auto, text, vector, or hybrid") if kind is not None: - AtomicMemoryContent(kind=kind, text="validation") + AtomicMemoryContent( + kind=kind, + text="validation", + description="validation", + ) started = asyncio.get_running_loop().time() mode, vector, query_embedding, embedding_calls, embedding_fallback = await self._resolve_query_embedding( query, diff --git a/src/powercontext/builtin/runtime/atomic_memory_processing.py b/src/powercontext/builtin/runtime/atomic_memory_processing.py index 5115bf1d3b..31e97973b7 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_processing.py +++ b/src/powercontext/builtin/runtime/atomic_memory_processing.py @@ -22,7 +22,7 @@ from contextlib import asynccontextmanager, nullcontext from dataclasses import dataclass, replace from hashlib import sha256 -from typing import TYPE_CHECKING, Literal, NoReturn +from typing import TYPE_CHECKING, Literal, NoReturn, cast from sqlalchemy import func, literal, select, true, tuple_, update from sqlalchemy.exc import OperationalError @@ -507,7 +507,11 @@ async def _prepare(self, scope_id, eligible, pipeline, context): key = workset.add( AtomicMemoryWorkingItem( key=f"candidate:{ordinal}", - content=AtomicMemoryContent(kind=candidate.kind, text=candidate.text), + content=AtomicMemoryContent( + kind=candidate.kind, + text=candidate.text, + description=candidate.description, + ), evidence=selected, sources=tuple(item.source_ref for item in selected), changed=True, @@ -584,7 +588,10 @@ async def _load_related_items(self, connection, scope_id, hits, context, workset ) except AccessDeniedError: continue - content = AtomicMemoryContent(kind=hit.kind, text=hit.text) + content = cast( + AtomicMemoryContent, + self.application.artifacts.decode_content(by_id[hit.artifact_ref.artifact_id]), + ).without_creation() supported = AtomicMemoryArtifactEvidence( evidence_id=f"artifact:{read.ref.artifact_id}@{read.ref.revision}", artifact_ref=read.ref, diff --git a/src/powercontext/builtin/runtime/atomic_memory_rebuild.py b/src/powercontext/builtin/runtime/atomic_memory_rebuild.py index 309a716921..a3a0dadd70 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_rebuild.py +++ b/src/powercontext/builtin/runtime/atomic_memory_rebuild.py @@ -22,7 +22,7 @@ from time import perf_counter from pydantic import BaseModel, Field -from sqlalchemy import and_, exists, func, inspect, select, tuple_ +from sqlalchemy import and_, exists, func, inspect, literal, select, tuple_ from sqlalchemy.engine import RowMapping from sqlalchemy.ext.asyncio import AsyncConnection @@ -166,6 +166,7 @@ async def _projection_differences( "projection_format": ATOMIC_MEMORY_PROJECTION_FORMAT, "kind": content.kind, "text": content.text, + "description": content.description, "searchable_text": analyze_text(atomic_memory_embedding_input(content.kind, content.text)), "profile_fingerprint": fingerprint, "embedding_input_hash": None @@ -195,6 +196,15 @@ async def verify_atomic_memory_current( artifacts = ArtifactRepository((AtomicMemory,)) profile = index.capabilities.embedding_profile fingerprint = None if profile is None else atomic_memory_profile_fingerprint(profile) + columns = await connection.run_sync( + lambda sync: {column["name"] for column in inspect(sync).get_columns(index.table.name)} + ) + # Intermediate registered revisions predate the nullable description column. + # Compare absent metadata as null without selecting a column not yet added. + projection_columns = [ + literal(None).label("description") if column.name == "description" and column.name not in columns else column + for column in index.table.c + ] errors: list[str] = [] after = None active = 0 @@ -208,7 +218,7 @@ async def verify_atomic_memory_current( row = ( ( await connection.execute( - select(index.table).where( + select(*projection_columns).where( index.table.c.scope_id == scope_id, index.table.c.artifact_id == artifact_id ) ) diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 28b2ae2729..95c047559d 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -83,6 +83,7 @@ "get_experience", "get_handoff_report", "get_memory_entry", + "get_recall_manifest", "get_scope", "get_skill", "get_topic_memory", diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index d26e9162ce..982f42d60d 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -995,6 +995,12 @@ async def get_atomic_memory_state(self, scope_id: str, artifact_id: str) -> atom path_parameters={"scope_id": scope_id, "artifact_id": artifact_id}, ) + async def get_recall_manifest( + self, request: atomic_models.RecallManifestRequest + ) -> atomic_models.RecallManifestResponse: + """Read bounded Atomic Memory metadata before fetching selected exact revisions.""" + return await self._request(atomic_operations.GET_RECALL_MANIFEST, request) + async def list_atomic_memories( self, request: atomic_models.ListAtomicMemoryRequest ) -> atomic_models.ListAtomicMemoryResponse: diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index dcb6b7c740..594ef984fb 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -72,6 +72,7 @@ AtomicMemoryInput, AtomicMemoryLifecycleRequest, AtomicMemoryMutationResponse, + AtomicMemoryReadContent, AtomicMemoryRecord, AtomicMemoryRestorationPreview, AtomicMemoryRestorationPreviewRequest, @@ -326,6 +327,9 @@ QueryArtifactTagsRequest, ReadinessResponse, ReadinessStatus, + RecallManifestEntry, + RecallManifestRequest, + RecallManifestResponse, RecallTokenDay, RecallTokenStatistics, RecallTokenValue, @@ -535,6 +539,7 @@ "AtomicMemoryInput", "AtomicMemoryLifecycleRequest", "AtomicMemoryMutationResponse", + "AtomicMemoryReadContent", "AtomicMemoryRecord", "AtomicMemoryRestorationPreview", "AtomicMemoryRestorationPreviewRequest", @@ -789,6 +794,9 @@ "QueryArtifactTagsRequest", "ReadinessResponse", "ReadinessStatus", + "RecallManifestEntry", + "RecallManifestRequest", + "RecallManifestResponse", "RecallTokenDay", "RecallTokenStatistics", "RecallTokenValue", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index f97992dcb7..491358cba3 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -58,6 +58,15 @@ class ServerInfo(BaseModel): ] +class Tag(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(min_length=1)] + + +class Code(StrEnum): + PAGE = "page" + EMPTY_INVENTORY = "empty_inventory" + + class AtomicMemoryState(StrEnum): ACTIVE = "active" FORGOTTEN = "forgotten" @@ -77,10 +86,6 @@ class AtomicMemoryTarget(BaseModel): revision: Annotated[StrictInt | None, Field(ge=1)] = None -class Tag(RootModel[StrictStr]): - root: Annotated[StrictStr, Field(min_length=1)] - - class MatchedByEnum(StrEnum): TEXT = "text" VECTOR = "vector" @@ -2178,6 +2183,15 @@ class RememberMemoryRequest(BaseModel): text: Annotated[StrictStr, Field(description="Must not exceed 8192 UTF-8 bytes after normalization.", min_length=1)] reason: Annotated[StrictStr | None, Field(max_length=512)] = None expected_revision: Annotated[StrictInt | None, Field(ge=1)] = None + description: Annotated[ + StrictStr | None, + Field( + description="Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model.", + max_length=200, + min_length=1, + pattern="^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + ), + ] = None class RetireMemoryEntryRequest(BaseModel): @@ -2567,7 +2581,7 @@ class TagTarget(RootModel[ArtifactTagTarget | MemoryEntryTagTarget]): root: Annotated[ArtifactTagTarget | MemoryEntryTagTarget, Field(discriminator="type")] -class Tag2(RootModel[StrictStr]): +class Tag3(RootModel[StrictStr]): root: Annotated[StrictStr, Field(max_length=64, min_length=1)] @@ -2575,7 +2589,7 @@ class TagFilter(BaseModel): model_config = ConfigDict( extra="forbid", ) - tags: Annotated[list[Tag2], Field(max_length=16, min_length=1)] + tags: Annotated[list[Tag3], Field(max_length=16, min_length=1)] match: TagMatch = TagMatch.ALL @@ -2583,14 +2597,14 @@ class ReplaceArtifactTagsRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - tags: Annotated[list[Tag2], Field(max_length=32, min_length=0)] + tags: Annotated[list[Tag3], Field(max_length=32, min_length=0)] class QueryArtifactTagsRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - tags: Annotated[list[Tag2], Field(max_length=16, min_length=1)] + tags: Annotated[list[Tag3], Field(max_length=16, min_length=1)] match: TagMatch = TagMatch.ALL families: Annotated[ list[TaggableArtifactFamily] | None, @@ -2612,7 +2626,7 @@ class ArtifactTagSet(BaseModel): ) scope_id: StrictStr target: TagTarget - tags: Annotated[list[Tag2], Field(max_length=32, min_length=0)] + tags: Annotated[list[Tag3], Field(max_length=32, min_length=0)] tag_digest: Annotated[ StrictStr, Field( @@ -2628,7 +2642,7 @@ class TaggedTarget(BaseModel): ) scope_id: StrictStr target: TagTarget - tags: Annotated[list[Tag2], Field(max_length=32, min_length=0)] + tags: Annotated[list[Tag3], Field(max_length=32, min_length=0)] tag_digest: Annotated[ StrictStr, Field( @@ -3533,11 +3547,76 @@ class CatalogTagResult(BaseModel): ) scope_id: Annotated[StrictStr, Field(min_length=1)] target: TagTarget - tags: Annotated[list[Tag2], Field(max_length=32)] + tags: Annotated[list[Tag3], Field(max_length=32)] tag_digest: Annotated[StrictStr, Field(min_length=1)] etag: Annotated[StrictStr, Field(min_length=1)] +class RecallManifestRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + kind: Annotated[StrictStr | None, Field(min_length=1)] = None + tags: Annotated[list[Tag] | None, Field(max_length=16)] = None + tag_match: TagMatch | None = None + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + cursor: Annotated[StrictStr | None, Field(max_length=4096)] = None + + +class RecallManifestEntry(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + ref: ArtifactReference + kind: StrictStr + description: Annotated[ + StrictStr | None, + Field( + description="Stored summary, or null for historical records awaiting explicit summary completion. No body is read to synthesize it.", + max_length=200, + min_length=1, + pattern="^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + ), + ] + state_version: Annotated[StrictInt, Field(ge=0)] + + +class RecallManifestResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: Annotated[list[RecallManifestEntry], Field(max_length=100)] + next_cursor: Annotated[StrictStr | None, Field(...)] + code: Code + + +class AtomicMemoryReadContent(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + schema_: Annotated[AtomicMemoryContentSchema, Field(alias="schema")] = ( + AtomicMemoryContentSchema.POWERCONTEXT_ATOMIC_MEMORY_V1 + ) + kind: Annotated[StrictStr, Field(max_length=128, min_length=1)] + text: Annotated[ + StrictStr, + Field( + description="Memory text is NFC-normalized and trimmed before enforcing an 8192 UTF-8 byte limit.", + min_length=1, + ), + ] + description: Annotated[ + StrictStr | None, + Field( + description="A concise single-line summary of final text, in the same language. Required for new writes.", + max_length=200, + min_length=1, + pattern="^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + ), + ] = None + + class AtomicMemoryWriteContent(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3553,6 +3632,15 @@ class AtomicMemoryWriteContent(BaseModel): min_length=1, ), ] + description: Annotated[ + StrictStr | None, + Field( + description="Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model.", + max_length=200, + min_length=1, + pattern="^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + ), + ] = None class CreateAtomicMemoryArtifactRequest(BaseModel): @@ -5178,7 +5266,7 @@ class Candidate(BaseModel): ExperienceProposal | SkillProposal | ProfileCandidateProposal - | AtomicMemoryWriteContent + | AtomicMemoryReadContent | TopicMemoryDreamProposal | HandoffContent | PromptContent diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index b96333617a..aa0ed2e6c5 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -142,6 +142,8 @@ PutProfilePolicyRequest, QueryArtifactTagsRequest, ReadinessResponse, + RecallManifestRequest, + RecallManifestResponse, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, RecordBootstrapDeliveryRequest, @@ -249,6 +251,32 @@ class AccessRequirement(BaseModel): resolver: str +GET_RECALL_MANIFEST = Operation[RecallManifestRequest, RecallManifestResponse]( + method="POST", + path="/v1/recall/manifest", + operation_id="get_recall_manifest", + request_type=RecallManifestRequest, + request_location="body", + path_parameters=(), + response_type=RecallManifestResponse, + success_status=200, + summary="Read a bounded metadata-only recall manifest", + tags=("atomic-memory",), + scope_mode="current", + responses={ + 200: {"description": "Operation completed."}, + 400: {"$ref": "#/components/responses/InvalidRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + LIST_ATOMIC_MEMORIES = Operation[ListAtomicMemoryRequest, ListAtomicMemoryResponse]( method="POST", path="/v1/atomic-memory/list", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 3f4c08e56b..9a56cdffd9 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -10,6 +10,46 @@ "version": "1.4.1", }, "paths": { + "/v1/recall/manifest": { + "post": { + "tags": ["atomic-memory"], + "summary": "Read a bounded metadata-only recall manifest", + "description": "Lists active Atomic Memory metadata " + "only. Empty inventory never proves the " + "Scope has no context or authorizes " + "skipping prepare_context. Fetch " + "selected exact refs with " + "get_artifact_revision. Pagination is " + "live keyset order, not a cross-request " + "snapshot.", + "operationId": "get_recall_manifest", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RecallManifestRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "Operation completed.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RecallManifestResponse"}} + }, + }, + "400": {"$ref": "#/components/responses/InvalidRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "500": {"$ref": "#/components/responses/InternalError"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-scope-mode": "current", + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + } + }, "/v1/atomic-memory/list": { "post": { "tags": ["atomic-memory"], @@ -5592,7 +5632,120 @@ "limits, inventory and " "authorization-principal identity.", }, + "RecallManifestRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "kind": {"type": "string", "minLength": 1, "nullable": True}, + "tags": {"items": {"type": "string", "minLength": 1}, "type": "array", "maxItems": 16}, + "tag_match": {"$ref": "#/components/schemas/TagMatch"}, + "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, + "cursor": {"type": "string", "maxLength": 4096, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id"], + }, + "RecallManifestEntry": { + "properties": { + "ref": {"$ref": "#/components/schemas/ArtifactReference"}, + "kind": {"type": "string"}, + "description": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "pattern": "^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + "description": "Stored " + "summary, " + "or " + "null " + "for " + "historical " + "records " + "awaiting " + "explicit " + "summary " + "completion. " + "No " + "body " + "is " + "read " + "to " + "synthesize " + "it.", + "nullable": True, + }, + "state_version": {"type": "integer", "minimum": 0.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["ref", "kind", "description", "state_version"], + }, + "RecallManifestResponse": { + "properties": { + "items": { + "items": {"$ref": "#/components/schemas/RecallManifestEntry"}, + "type": "array", + "maxItems": 100, + }, + "next_cursor": {"type": "string", "nullable": True}, + "code": {"type": "string", "enum": ["page", "empty_inventory"]}, + }, + "additionalProperties": False, + "type": "object", + "required": ["items", "next_cursor", "code"], + }, "AtomicMemoryState": {"type": "string", "enum": ["active", "forgotten", "merged", "retired"]}, + "AtomicMemoryReadContent": { + "properties": { + "schema": { + "$ref": "#/components/schemas/AtomicMemoryContentSchema", + "default": "powercontext.atomic-memory.v1", + }, + "kind": {"type": "string", "maxLength": 128, "minLength": 1}, + "text": { + "type": "string", + "minLength": 1, + "description": "Memory " + "text " + "is " + "NFC-normalized " + "and " + "trimmed " + "before " + "enforcing " + "an " + "8192 " + "UTF-8 " + "byte " + "limit.", + }, + "description": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "pattern": "^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + "description": "A " + "concise " + "single-line " + "summary " + "of " + "final " + "text, " + "in " + "the " + "same " + "language. " + "Required " + "for " + "new " + "writes.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "text"], + }, "AtomicMemoryWriteContent": { "properties": { "schema": { @@ -5617,6 +5770,46 @@ "byte " "limit.", }, + "description": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "pattern": "^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + "description": "Optional " + "supplied " + "single-line " + "summary. " + "When " + "omitted, " + "the " + "Server " + "stores " + "a " + "whitespace-collapsed " + "text " + "prefix " + "of " + "at " + "most " + "200 " + "Unicode " + "characters, " + "including " + "a " + "trailing " + "ellipsis " + "when " + "truncated. " + "Explicit " + "writes " + "do " + "not " + "invoke " + "a " + "generation " + "model.", + "nullable": True, + }, }, "additionalProperties": False, "type": "object", @@ -6772,7 +6965,7 @@ {"$ref": "#/components/schemas/ExperienceProposal"}, {"$ref": "#/components/schemas/SkillProposal"}, {"$ref": "#/components/schemas/ProfileCandidateProposal"}, - {"$ref": "#/components/schemas/AtomicMemoryWriteContent"}, + {"$ref": "#/components/schemas/AtomicMemoryReadContent"}, {"$ref": "#/components/schemas/TopicMemoryDreamProposal"}, {"$ref": "#/components/schemas/HandoffContent"}, {"$ref": "#/components/schemas/PromptContent"}, @@ -10707,6 +10900,46 @@ }, "reason": {"type": "string", "maxLength": 512, "nullable": True}, "expected_revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "description": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "pattern": "^[^\\r\\n\\u0085\\u2028\\u2029]*\\S[^\\r\\n\\u0085\\u2028\\u2029]*$", + "description": "Optional " + "supplied " + "single-line " + "summary. " + "When " + "omitted, " + "the " + "Server " + "stores " + "a " + "whitespace-collapsed " + "text " + "prefix " + "of " + "at " + "most " + "200 " + "Unicode " + "characters, " + "including " + "a " + "trailing " + "ellipsis " + "when " + "truncated. " + "Explicit " + "writes " + "do " + "not " + "invoke " + "a " + "generation " + "model.", + "nullable": True, + }, }, "additionalProperties": False, "type": "object", diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 980349239f..ae90e11d54 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -3186,7 +3186,7 @@ async def remember_memory( if request.expected_revision is not None: raise BaseOperationNotSupportedError("artifact_family", "memory", "collection revision precondition") result = await _scoped_atomic_memory(application, request.scope_id).create( - (AtomicMemoryContent(kind=request.kind, text=request.text),), + (AtomicMemoryContent(kind=request.kind, text=request.text, description=request.description),), context=_atomic_execution_context(http_request, REMEMBER_MEMORY.operation_id), ) return mapping.mutation_response(result) diff --git a/src/powercontext/server/atomic_memory.py b/src/powercontext/server/atomic_memory.py index 51382291ce..80742420f0 100644 --- a/src/powercontext/server/atomic_memory.py +++ b/src/powercontext/server/atomic_memory.py @@ -42,7 +42,10 @@ def _tag_filter(request): if request.tag_match is not None: raise InvalidBaseAccessRequestError("tag_match", "requires tags") return None - return TagFilter(tags=tuple(request.tags), match="all" if request.tag_match is None else request.tag_match.value) + return TagFilter( + tags=tuple(tag.root for tag in request.tags), + match="all" if request.tag_match is None else request.tag_match.value, + ) def _scoped(application, scope_id): @@ -85,6 +88,20 @@ async def get_atomic_memory_state( **state.model_dump(mode="json", exclude={"ref"}), }) + async def get_recall_manifest( + request: models.RecallManifestRequest, + http_request: Request, + application: Annotated[Any, Depends(application_dependency)], + ) -> models.RecallManifestResponse: + result = await _scoped(application, request.scope_id).manifest( + kind=request.kind, + tag_filter=_tag_filter(request), + limit=request.limit, + cursor=request.cursor, + context=execution_context(http_request, "get_recall_manifest"), + ) + return models.RecallManifestResponse.model_validate(result.model_dump(mode="json")) + async def list_atomic_memories( request: models.ListAtomicMemoryRequest, http_request: Request, @@ -227,6 +244,7 @@ async def restore_atomic_memory( for operation, endpoint in ( (operations.GET_ATOMIC_MEMORY_STATE, get_atomic_memory_state), + (operations.GET_RECALL_MANIFEST, get_recall_manifest), (operations.LIST_ATOMIC_MEMORIES, list_atomic_memories), (operations.SEARCH_ATOMIC_MEMORY, search_atomic_memory), (operations.MERGE_ATOMIC_MEMORIES, merge_atomic_memories), diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 4a31e2040a..c08500a626 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -176,6 +176,7 @@ ActivateHandoffRequest, ApproveCandidateRequest, ArtifactReference, + AtomicMemoryReadContent, BootstrapDeliveryReceipt, Candidate, CandidateAudit, @@ -1140,6 +1141,7 @@ def reviewed_content( | SkillProposal | ProfileWriteContent | AtomicMemoryContent + | AtomicMemoryReadContent | TopicMemoryDreamProposal | TransportHandoffContent | PromptContent, @@ -1158,7 +1160,7 @@ def reviewed_content( return runtime_handoff_content(value) if isinstance(value, TopicMemoryDreamProposal): return RuntimeTopicMemoryContent.model_validate(value.model_dump(mode="json")) - if isinstance(value, AtomicMemoryContent): + if isinstance(value, (AtomicMemoryContent, AtomicMemoryReadContent)): return RuntimeAtomicMemoryContent.model_validate(value.model_dump(mode="json")) if isinstance(value, ProfileWriteContent): return RuntimeProfileWriteContent.model_validate(value.model_dump(mode="json")) @@ -1173,7 +1175,7 @@ def reviewed_proposal( ExperienceProposal | SkillProposal | ProfileCandidateProposal - | AtomicMemoryContent + | AtomicMemoryReadContent | TopicMemoryDreamProposal | TransportHandoffContent | PromptContent @@ -1185,7 +1187,9 @@ def reviewed_proposal( if isinstance(value, RuntimeTopicMemoryContent): return TopicMemoryDreamProposal.model_validate(value.model_dump(mode="json")) if isinstance(value, RuntimeAtomicMemoryContent): - return AtomicMemoryContent.model_validate(value.model_dump(mode="json", by_alias=True, exclude={"creation"})) + return AtomicMemoryReadContent.model_validate( + value.model_dump(mode="json", by_alias=True, exclude={"creation"}) + ) if isinstance(value, RuntimeProfileCandidateProposal): return ProfileCandidateProposal.model_validate(value.model_dump(mode="json", by_alias=True)) if isinstance(value, ExperienceContent): diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index 40a206c307..2491471b97 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -114,6 +114,9 @@ Ordinary coding needs no routine Memory calls. Use sufficient current context when continuing work. For an explicit memory search (search my memories / 搜索记忆), call search_memory with a focused query, mode auto, and at most eight hits. search_memory and list_memory_entries return current Atomic Memory records with real ArtifactRef and state_version. +Use get_recall_manifest for a bounded metadata-only active Atomic Memory inventory, then fetch selected exact refs. +An empty manifest never proves the Scope empty or authorizes skipping normal context preparation. Null summaries are historical +metadata gaps, not absent memories. Other context families are not covered by this manifest. Use list_atomic_memories for explicit state filters and pagination. get_memory_entry accepts only a legacy logical target; it returns current Atomic Memory and does not follow merged_into_id automatically. Historical citations are unsupported. @@ -157,6 +160,7 @@ _MCP_OPERATION_IDS = frozenset({ "get_atomic_memory_state", "list_atomic_memories", + "get_recall_manifest", "search_atomic_memory", "merge_atomic_memories", "change_atomic_memory_lifecycle", @@ -215,6 +219,7 @@ _MCP_READ_ONLY_OPERATION_IDS = frozenset({ "get_atomic_memory_state", "list_atomic_memories", + "get_recall_manifest", "search_atomic_memory", "preview_atomic_memory_restoration", GET_ARTIFACT.operation_id, diff --git a/tests/builtin/artifacts/atomic_memory/test_content.py b/tests/builtin/artifacts/atomic_memory/test_content.py index b6c05b24f8..c65e080b59 100644 --- a/tests/builtin/artifacts/atomic_memory/test_content.py +++ b/tests/builtin/artifacts/atomic_memory/test_content.py @@ -14,10 +14,45 @@ from __future__ import annotations +from typing import Any, cast + import pytest from pydantic import ValidationError +from powercontext.artifacts import ArtifactLineage from powercontext.builtin.artifacts.atomic_memory import AtomicMemoryContent +from powercontext.builtin.artifacts.atomic_memory.service import AtomicMemoryMergeAdapter + + +def test_historical_restore_preserves_absent_description() -> None: + adapter = AtomicMemoryMergeAdapter(cast(Any, None)) + content = {"kind": "fact", "text": "A fact"} + restored = adapter.draft(content, ArtifactLineage(), historical=True) + assert restored.content.description is None + + +def test_new_write_preserves_description() -> None: + adapter = AtomicMemoryMergeAdapter(cast(Any, None)) + draft = adapter.draft({"kind": "fact", "text": "A fact", "description": "A summary"}, ArtifactLineage()) + assert draft.content.description == "A summary" + + +def test_legacy_content_serialization_preserves_missing_description() -> None: + payload = {"schema": "powercontext.atomic-memory.v1", "kind": "fact", "text": "A fact", "creation": None} + content = AtomicMemoryContent.model_validate(payload) + assert content.description is None + assert content.model_dump(mode="json", by_alias=True) == payload + + +def test_content_serialization_preserves_description() -> None: + content = AtomicMemoryContent(kind="fact", text="A complete fact", description="A summary") + assert content.model_dump(mode="json")["description"] == "A summary" + + +@pytest.mark.parametrize("description", ["", " ", "a\nb", "a\rb", "a" * 201]) +def test_content_rejects_invalid_description(description: str) -> None: + with pytest.raises(ValidationError): + AtomicMemoryContent(kind="fact", text="A fact", description=description) def test_atomic_memory_content_decodes_legacy_text_that_expands_under_nfc() -> None: @@ -43,3 +78,15 @@ def test_atomic_memory_content_measures_byte_limit_after_normalization(text: str def test_atomic_memory_content_rejects_empty_or_oversized_normalized_text(text: str) -> None: with pytest.raises(ValidationError): AtomicMemoryContent(kind="fact", text=text) + + +def test_legacy_candidate_proposal_remains_transport_readable() -> None: + from powercontext.server.mapping import reviewed_proposal + + legacy = AtomicMemoryContent(kind="fact", text="A historical candidate") + mapped = reviewed_proposal(legacy) + from powercontext.http import AtomicMemoryReadContent + + assert isinstance(mapped, AtomicMemoryReadContent) + assert mapped.text == legacy.text + assert mapped.description is None diff --git a/tests/builtin/artifacts/atomic_memory/test_extraction.py b/tests/builtin/artifacts/atomic_memory/test_extraction.py new file mode 100644 index 0000000000..5b68e751d7 --- /dev/null +++ b/tests/builtin/artifacts/atomic_memory/test_extraction.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. + +"""Extraction candidates contain facts and evidence, without server-owned merge metadata.""" + +import pytest +from pydantic import ValidationError + +from powercontext.builtin.artifacts.atomic_memory.extraction import AtomicMemoryExtractionOutput + + +@pytest.mark.parametrize("creation", [None, {"input_artifact_ids": ["art_existing_a", "art_existing_b"]}]) +def test_extraction_rejects_model_supplied_creation(creation): + with pytest.raises(ValidationError, match="extra_forbidden"): + AtomicMemoryExtractionOutput.model_validate({ + "candidates": [ + { + "kind": "fact", + "text": "Use a reviewed rollout policy.", + "description": "Rollout policy", + "evidence_ids": ["source:1"], + "creation": creation, + } + ], + }) diff --git a/tests/builtin/artifacts/atomic_memory/test_forgetting.py b/tests/builtin/artifacts/atomic_memory/test_forgetting.py index 43c31b74b6..9b55730115 100644 --- a/tests/builtin/artifacts/atomic_memory/test_forgetting.py +++ b/tests/builtin/artifacts/atomic_memory/test_forgetting.py @@ -63,7 +63,9 @@ async def scenario(): memory = contexts.atomic_memory.for_scope(scope_id) created = [ await contexts.records.create_artifact( - scope_id, "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": fact(i)}) + scope_id, + "atomic-memory", + ArtifactWrite(content={"kind": "fact", "text": fact(i), "description": "Fact summary"}), ) for i in range(12) ] @@ -130,7 +132,9 @@ async def scenario(): memory = contexts.atomic_memory.for_scope(scope_id) created = [ await contexts.records.create_artifact( - scope_id, "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": fact(i)}) + scope_id, + "atomic-memory", + ArtifactWrite(content={"kind": "fact", "text": fact(i), "description": "Fact summary"}), ) for i in range(3) ] @@ -144,7 +148,9 @@ async def scenario(): ) before = await atomic_row_counts(contexts, scope_id) fourth = await contexts.records.create_artifact( - scope_id, "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": fact(4)}) + scope_id, + "atomic-memory", + ArtifactWrite(content={"kind": "fact", "text": fact(4), "description": "Fact summary"}), ) assert fourth.artifact_id not in {item.artifact_id for item in created} assert await atomic_row_counts(contexts, scope_id) == (before[0] + 1, before[1] + 1, 2) @@ -180,7 +186,9 @@ async def scenario(): await contexts.get(scope_id) created = [ await contexts.records.create_artifact( - scope_id, "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": fact(i)}) + scope_id, + "atomic-memory", + ArtifactWrite(content={"kind": "fact", "text": fact(i), "description": "Fact summary"}), ) for i in range(2) ] diff --git a/tests/builtin/artifacts/atomic_memory/test_reconciliation.py b/tests/builtin/artifacts/atomic_memory/test_reconciliation.py index dfbb4e421e..474608b847 100644 --- a/tests/builtin/artifacts/atomic_memory/test_reconciliation.py +++ b/tests/builtin/artifacts/atomic_memory/test_reconciliation.py @@ -71,7 +71,9 @@ async def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelRespons compared_ids=("candidate:0",), target_ids=("candidate:0",), content=AtomicMemoryReconciliationContent( - kind="fact", text="The project selected OceanBase for persistence." + kind="fact", + text="The project selected OceanBase for persistence.", + description="OceanBase selected for persistence", ), evidence_ids=("source:1",), reason="Combine the two supported statements.", @@ -93,6 +95,7 @@ async def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelRespons key = workset.apply("candidate:1", related, result.output) assert workset.items[key].origins == () assert workset.items[key].content.text == "The project selected OceanBase for persistence." + assert workset.items[key].content.description == "OceanBase selected for persistence" else: with pytest.raises(InvalidInferenceOutputError): await generator.generate(request) @@ -109,7 +112,7 @@ async def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelRespons schema = output.json_schema (content_schema,) = tuple(item for item in schema["properties"]["content"]["anyOf"] if "$ref" in item) content = schema["$defs"][content_schema["$ref"].rsplit("/", 1)[-1]] - assert set(content["properties"]) == {"kind", "text"} + assert set(content["properties"]) == {"kind", "text", "description"} assert content["additionalProperties"] is False assert "AtomicMemoryCreation" not in schema.get("$defs", {}) return ModelResponse(parts=[TextPart('{"action":"noop","compared_ids":[],"reason":"Unsupported."}')]) @@ -148,7 +151,7 @@ def test_reconciliation_output_rejects_externally_supplied_metadata(metadata: di AtomicMemoryReconciliationOutput.model_validate({ "action": "create", "compared_ids": [], - "content": {"kind": "fact", "text": "Supplied fact.", **metadata}, + "content": {"kind": "fact", "text": "Supplied fact.", "description": "Supplied fact", **metadata}, "evidence_ids": ["source:1"], "reason": "Supported by the Source.", }) @@ -159,4 +162,4 @@ def test_reconciliation_output_json_schema_omits_service_owned_metadata(mode) -> schema = AtomicMemoryReconciliationOutput.model_json_schema(mode=mode) (content_schema,) = tuple(item for item in schema["properties"]["content"]["anyOf"] if "$ref" in item) content = schema["$defs"][content_schema["$ref"].rsplit("/", 1)[-1]] - assert set(content["properties"]) == {"kind", "text"} + assert set(content["properties"]) == {"kind", "text", "description"} diff --git a/tests/builtin/artifacts/memory/test_prompts.py b/tests/builtin/artifacts/memory/test_prompts.py index a43ffb0afb..1050d3518b 100644 --- a/tests/builtin/artifacts/memory/test_prompts.py +++ b/tests/builtin/artifacts/memory/test_prompts.py @@ -47,14 +47,15 @@ def test_conversation_profile_selects_its_versioned_policy() -> None: assert CONVERSATION_MEMORY_EXTRACTION_INSTRUCTIONS != MEMORY_EXTRACTION_INSTRUCTIONS -def test_atomic_memory_profiles_share_the_subject_bound_v2_contract() -> None: +def test_atomic_memory_profiles_share_the_subject_bound_description_contract() -> None: for profile in MemoryExtractionProfile: instructions = atomic_memory_extraction_instructions(profile) assert atomic_memory_extraction_instructions_version(profile) == ( - f"powercontext.atomic-memory.extract.{profile.value}.v2" + f"powercontext.atomic-memory.extract.{profile.value}.v3" ) assert "subject-bound declarative proposition" in instructions + assert "kind, text and description" in instructions assert 'Do not address a future agent as "you"' in instructions assert "not Atomic Memory" in instructions assert "Preserve literal commands, code, paths, configuration keys" in instructions diff --git a/tests/builtin/artifacts/prompt/test_prompt_content.py b/tests/builtin/artifacts/prompt/test_prompt_content.py index deeef24e11..b57500f61f 100644 --- a/tests/builtin/artifacts/prompt/test_prompt_content.py +++ b/tests/builtin/artifacts/prompt/test_prompt_content.py @@ -71,10 +71,12 @@ def test_atomic_memory_auto_uses_the_versioned_subject_bound_contract(profile: M resolved = definition.resolve("scope-a", None) - assert definition.builtin_version == f"powercontext.atomic-memory.extract.{profile.value}.v2" - assert definition.definition_version == "powercontext.prompt.atomic-memory.extract.v1" + assert definition.builtin_version == f"powercontext.atomic-memory.extract.{profile.value}.v3" + assert definition.definition_version == "powercontext.prompt.atomic-memory.extract.v2" assert resolved.compiled_instructions == atomic_memory_extraction_instructions(profile) assert resolved.builtin_version == definition.builtin_version + assert "subject-bound declarative proposition" in resolved.compiled_instructions + assert "kind, text and description" in resolved.compiled_instructions def test_custom_atomic_memory_prompt_cannot_remove_subject_and_procedure_invariants() -> None: @@ -91,6 +93,7 @@ def test_custom_atomic_memory_prompt_cannot_remove_subject_and_procedure_invaria assert "subject-bound declarative proposition" in resolved.compiled_instructions assert 'Do not address a future agent as "you"' in resolved.compiled_instructions assert "not Atomic Memory" in resolved.compiled_instructions + assert "kind, text and description" in resolved.compiled_instructions assert "Copy every instruction exactly as written." in resolved.compiled_instructions diff --git a/tests/builtin/artifacts/prompt/test_prompt_validation.py b/tests/builtin/artifacts/prompt/test_prompt_validation.py index 1d0149d93d..479280b863 100644 --- a/tests/builtin/artifacts/prompt/test_prompt_validation.py +++ b/tests/builtin/artifacts/prompt/test_prompt_validation.py @@ -53,7 +53,12 @@ def _case(key: str) -> dict[str, Any]: # noqa: C901 - one branch per prompt key "content": "Prefers Chinese.", "source_metadata": {}, } - candidate = {"kind": "preference", "text": "Prefers Chinese.", "evidence_ids": [evidence["evidence_id"]]} + candidate = { + "kind": "preference", + "text": "Prefers Chinese.", + "description": "Chinese preference", + "evidence_ids": [evidence["evidence_id"]], + } if key == "atomic_memory.extract": return {"input": {"evidence": [evidence]}, "expected_output": {"candidates": [candidate]}} return { @@ -65,7 +70,7 @@ def _case(key: str) -> dict[str, Any]: # noqa: C901 - one branch per prompt key "expected_output": { "action": "create", "compared_ids": [], - "content": {"kind": "preference", "text": "Prefers Chinese."}, + "content": {"kind": "preference", "text": "Prefers Chinese.", "description": "Chinese preference"}, "evidence_ids": [evidence["evidence_id"]], "reason": "The supplied Source supports this independent preference.", }, diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py new file mode 100644 index 0000000000..6ff90fcf8e --- /dev/null +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -0,0 +1,132 @@ +# 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. + +"""Registered Atomic Memory metadata survives managed vector profile selection.""" + +import json +import shutil +import sqlite3 +from contextlib import closing + +import pytest + +from powercontext.builtin.artifacts.memory import EmbeddingProfile +from powercontext.builtin.persistence.migrations.deployment import server_bundle +from powercontext.builtin.persistence.migrations.models import MigrationError +from powercontext.builtin.persistence.migrations.mysql_schema import MySQLSchema, _parse_table +from powercontext.builtin.persistence.migrations.sqlite import SQLiteMigrationRunner +from powercontext.builtin.runtime.config import BuiltinConfig +from powercontext.builtin.runtime.processing_registry import canonical_processing_manifest + + +@pytest.mark.parametrize("source_release", [None, "1.4.0"]) +def test_managed_sqlite_vector_schema_includes_registered_nullable_description(tmp_path, source_release): + profile = EmbeddingProfile(profile_id="description-space", model="fixture", dimension=3) + bundle = server_bundle(embedding_profile=profile, source_release=source_release) + configuration = { + "embedding_enabled": True, + "embedding_profile": profile.model_dump(mode="json"), + "processing_manifest": canonical_processing_manifest(BuiltinConfig()), + } + database = tmp_path / "vector.db" + runner = SQLiteMigrationRunner(database, bundle, configuration=configuration) + result = runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True) + assert result.revision == "v1_4_1_005" + with closing(sqlite3.connect(database)) as connection: + columns = {row[1]: row for row in connection.execute("PRAGMA table_info(pc_atomic_memory_current)")} + assert columns["description"][2:4] == ("VARCHAR(200)", 0) + assert runner.verify().state == "ready" + + +def test_mysql_vector_admission_retains_description_and_exact_previous_revision(): + profile = EmbeddingProfile(profile_id="description-space", model="fixture", dimension=3) + plain = server_bundle() + vector = server_bundle(embedding_profile=profile) + assert vector.vector_profile is not None + inventory = {"tables": {}, "views": [], "triggers": []} + for resource in json.loads((plain.directory / "manifest.json").read_text())["mysql_state_layouts"]: + for entry in json.loads((plain.directory / resource).read_text())["revisions"].values(): + for change in entry["changes"]: + for name, table in change.items(): + if table is None: + del inventory["tables"][name] + else: + inventory["tables"][name] = table + for name, sql in vector.vector_profile.mysql_tables().items(): + if name == "pc_atomic_memory_current": + sql = sql.replace( + " PRIMARY KEY", + " `description` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,\n PRIMARY KEY", + ) + inventory["tables"][name] = _parse_table(sql)[1] + declaration = vector.vector_profile.layout["mysql_profile_table"] + name, table = _parse_table(declaration) + inventory["tables"][name] = table + schema = MySQLSchema(vector) + schema.verify(inventory, "v1_4_1_005") + assert schema.identify(inventory) == "v1_4_1_005" + description = inventory["tables"]["pc_atomic_memory_current"]["columns"].pop("description") + schema.verify(inventory, "v1_4_1_004") + recovery = schema.recovery(inventory, "v1_4_1_004") + assert recovery.revision == "v1_4_1_005" and len(recovery.pending_statements) == 1 + with pytest.raises(MigrationError, match="incompatible_schema"): + schema.verify(inventory, "v1_4_1_005") + inventory["tables"]["pc_atomic_memory_current"]["columns"]["description"] = description + description["nullable"] = False + with pytest.raises(MigrationError, match="incompatible_schema"): + schema.verify(inventory, "v1_4_1_005") + + +@pytest.mark.parametrize("source_release", [None, "1.4.0"]) +@pytest.mark.parametrize("vectors", [False, True]) +def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tmp_path, source_release, vectors): + from powercontext.builtin.persistence.migrations.bundle import MigrationBundle + + profile = EmbeddingProfile(profile_id="owner-description", model="fixture", dimension=3) if vectors else None + current = server_bundle(embedding_profile=profile, source_release=source_release) + directory = tmp_path / "owner-bundle" + shutil.copytree(current.snapshot_directory, directory, ignore=shutil.ignore_patterns("__pycache__")) + manifest_path = directory / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + resources = manifest["revision_resources"].pop("v1_4_1_005") + for resource in (*resources, "versions/v1_4_1_005_atomic_memory_description.py", "mysql/versions/v1_4_1_005.py"): + (directory / resource).unlink() + del manifest["sqlite_fingerprints"]["v1_4_1_005"] + del manifest["domain_readiness"]["v1_4_1_005"] + manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_005.json") + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + previous = MigrationBundle(directory, embedding_profile=profile) + configuration = { + "embedding_enabled": profile is not None, + "processing_manifest": canonical_processing_manifest(BuiltinConfig()), + } + if profile is not None: + configuration["embedding_profile"] = profile.model_dump(mode="json") + database = tmp_path / "owner.db" + old = SQLiteMigrationRunner(database, previous, configuration=configuration) + assert old.apply(plan_id=old.plan().plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_4_1_004" + owner_record = ("original-operation", "original-deployment", "original-scope", 0, '{"principal":"原始身份"}') + with closing(sqlite3.connect(database)) as connection, connection: + connection.execute("INSERT INTO pc_access_owner_recovery VALUES (?,?,?,?,?)", owner_record) + original_evidence = json.loads(old.evidence_path.read_text(encoding="utf-8")) + upgraded = SQLiteMigrationRunner(database, current, configuration=configuration) + plan = upgraded.plan() + assert plan.source_revision == "v1_4_1_004" and plan.revisions == ("v1_4_1_005",) + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_4_1_005" + assert upgraded.verify().state == "ready" + evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) + for revision, checksum in original_evidence["release_checksums"].items(): + assert evidence["release_checksums"][revision] == checksum + with closing(sqlite3.connect(database)) as connection: + assert connection.execute("SELECT * FROM pc_access_owner_recovery").fetchall() == [owner_record] diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index 8a764c7b55..fcdb7c7529 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,8 +73,8 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_004" - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004") + assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_005" + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") assert dump(source.database) == before result = apply(source) assert result.readiness_scope == "server" and result.backup_state == "completed" diff --git a/tests/builtin/persistence/test_memory.py b/tests/builtin/persistence/test_memory.py index b2817817c9..d120f1fe0c 100644 --- a/tests/builtin/persistence/test_memory.py +++ b/tests/builtin/persistence/test_memory.py @@ -15,13 +15,16 @@ from __future__ import annotations import asyncio +from dataclasses import replace +import pytest from sqlalchemy import BigInteger, DateTime, Integer, String from sqlalchemy.dialects import mysql from sqlalchemy.schema import CreateTable, ForeignKeyConstraint, PrimaryKeyConstraint, UniqueConstraint from powercontext.artifacts import ArtifactLineage from powercontext.builtin.artifacts.atomic_memory import AtomicMemoryContent +from powercontext.builtin.artifacts.memory.errors import InvalidMemoryCandidateError from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.persistence.tables import ( MEMORY_ENTRY_HEADS_TABLE, @@ -93,7 +96,11 @@ async def scenario() -> None: connection, "project", "decision", - AtomicMemoryContent(kind="decision", text="Use one atomic composition boundary."), + AtomicMemoryContent( + kind="decision", + text="Use one atomic composition boundary.", + description="Fixture memory summary", + ), None, lineage=ArtifactLineage(sources=(SourceRef(source_type="content", source_id=source.name),)), ) @@ -106,13 +113,23 @@ async def scenario() -> None: first.ref.artifact_id, '"revision:1"', ArtifactWrite( - content={"kind": "decision", "text": "Use one atomic composition boundary for providers."} + content={ + "kind": "decision", + "text": "Use one atomic composition boundary for providers.", + "description": "Fixture memory summary", + } ), ) constraint = await contexts.records.create_artifact( "project", "atomic-memory", - ArtifactWrite(content={"kind": "constraint", "text": "Do not split the provider transaction."}), + ArtifactWrite( + content={ + "kind": "constraint", + "text": "Do not split the provider transaction.", + "description": "Fixture memory summary", + } + ), ) assert second.revision == 2 history = await contexts.records.list_artifact_revisions( @@ -148,7 +165,11 @@ async def scenario() -> None: "project", "atomic-memory", ArtifactWrite( - content={"kind": "decision", "text": "Rebuild search projections from authoritative revisions."} + content={ + "kind": "decision", + "text": "Rebuild search projections from authoritative revisions.", + "description": "Fixture memory summary", + } ), ) memory = contexts.atomic_memory.for_scope("project") @@ -187,3 +208,37 @@ async def scenario() -> None: assert tuple(source.content for source in right_sources if isinstance(source, ContentSource)) == ("right",) asyncio.run(scenario()) + + +def test_atomic_commit_rejects_a_prepared_write_with_its_description_removed() -> None: + async def scenario() -> None: + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + await contexts.get("project") + application = contexts.atomic_memory + content = AtomicMemoryContent(kind="fact", text="A bounded memory body.") + async with contexts.database.transaction() as connection: + plan = await application.service.inspect_change( + connection, "project", "summary-invariant", content, None + ) + prepared = await application.service.prepare_change(plan) + (write,) = prepared.plan.writes + assert write.draft is not None + assert write.draft.content.description == "A bounded memory body." + assert content.description is None + broken = replace( + prepared, + plan=replace( + prepared.plan, + writes=(replace(write, draft=write.draft.model_copy(update={"content": content})),), + ), + ) + async with contexts.database.transaction() as connection: + with pytest.raises(InvalidMemoryCandidateError, match="description is required for new writes"): + await application.service.commit(connection, broken, None) + assert (await application.for_scope("project").list()).items == () + async with contexts.database.transaction() as connection: + result = await application.service.commit(connection, prepared, None) + stored = await application.for_scope("project").get(result.primary.ref.artifact_id) + assert stored.artifact.content.description == "A bounded memory body." + + asyncio.run(scenario()) diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index 61cb536104..e66ca5ec1a 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -561,7 +561,7 @@ def test_oceanbase_server_upgrade_with_separate_code_cache_preserves_history( ) assert runner.plan(backup_policy="manual").source_revision == "s1300" result = _apply(runner, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -692,7 +692,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_004", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -702,7 +702,7 @@ def inspect_history(connection, identity, guard): for inspect in (runner.plan, runner.verify, runner.apply): with pytest.raises(MigrationError, match="checksum_conflict"): inspect() - assert backend.connections().run(inspect_history) == ("v1_4_1_004", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -769,7 +769,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_004", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -837,7 +837,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_4_1_004", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_4_1_005", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -992,7 +992,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan _load_full_server_baseline(backend) runner = _full_server_runner(backend) original = runner.plan(backup_policy="manual") - assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_004" + assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_005" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1014,7 +1014,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan result = _apply(resumed, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1094,7 +1094,7 @@ def client_active(connection: Connection | None, _identity: BackendIdentity, _gu # interruption. An arbitrary remote failure cannot be acknowledged blindly. resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journals[0].read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_owner_recovery_migration.py b/tests/builtin/persistence/test_owner_recovery_migration.py index e91b321acb..8203528ee2 100644 --- a/tests/builtin/persistence/test_owner_recovery_migration.py +++ b/tests/builtin/persistence/test_owner_recovery_migration.py @@ -39,6 +39,12 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + description = manifest["revision_resources"].pop("v1_4_1_005") + for resource in (*description, "versions/v1_4_1_005_atomic_memory_description.py", "mysql/versions/v1_4_1_005.py"): + (directory / resource).unlink() + del manifest["sqlite_fingerprints"]["v1_4_1_005"] + del manifest["domain_readiness"]["v1_4_1_005"] + manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_005.json") resources = manifest["revision_resources"].pop("v1_4_1_004") for resource in (*resources, "versions/v1_4_1_004_owner_recovery.py", "mysql/versions/v1_4_1_004.py"): (directory / resource).unlink() @@ -59,8 +65,8 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks original_evidence = json.loads(old.evidence_path.read_text(encoding="utf-8")) upgraded = SQLiteMigrationRunner(database, current, configuration=configuration(embedding)) plan = upgraded.plan() - assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004",) - assert apply(upgraded).revision == "v1_4_1_004" + assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004", "v1_4_1_005") + assert apply(upgraded).revision == "v1_4_1_005" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) @@ -90,7 +96,7 @@ def test_owner_recovery_mysql_registration_matches_native_character_and_key_defi ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci""" bundle = server_bundle() inventory = {"tables": {}, "views": [], "triggers": []} - for revision in bundle.revisions[:-1]: + for revision in bundle.revisions[: bundle.revisions.index("v1_4_1_004")]: path = bundle.directory / f"schemas/mysql-{revision}.json" fragment = json.loads(path.read_text(encoding="utf-8")) for change in fragment["revisions"][revision]["changes"]: diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index 16724dd2b2..8332d6fab8 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -35,6 +35,12 @@ def previous_bundle(directory, embedding): shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text()) + description = manifest["revision_resources"].pop("v1_4_1_005") + for resource in (*description, "versions/v1_4_1_005_atomic_memory_description.py", "mysql/versions/v1_4_1_005.py"): + (directory / resource).unlink() + del manifest["sqlite_fingerprints"]["v1_4_1_005"] + del manifest["domain_readiness"]["v1_4_1_005"] + manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_005.json") owner_resources = manifest["revision_resources"].pop("v1_4_1_004") for resource in (*owner_resources, "versions/v1_4_1_004_owner_recovery.py", "mysql/versions/v1_4_1_004.py"): (directory / resource).unlink() @@ -93,8 +99,8 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) plan = upgraded.plan() assert plan.source_revision == "s1401" - assert plan.revisions == ("s1402", "v1_4_1_004") - assert apply(upgraded).revision == "v1_4_1_004" + assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_005") + assert apply(upgraded).revision == "v1_4_1_005" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) diff --git a/tests/builtin/persistence/test_provider.py b/tests/builtin/persistence/test_provider.py index 7fed29a721..9746c195b5 100644 --- a/tests/builtin/persistence/test_provider.py +++ b/tests/builtin/persistence/test_provider.py @@ -69,7 +69,9 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(source.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", text=text, evidence_ids=(source.evidence_id,), description="Fixture memory summary" + ) for source in request.evidence if (text := memory_source_text(source)) is not None ) @@ -124,7 +126,9 @@ async def scenario() -> None: created = await contexts.records.create_artifact( "project", "atomic-memory", - ArtifactWrite(content={"kind": "fact", "text": "Directly managed."}), + ArtifactWrite( + content={"kind": "fact", "text": "Directly managed.", "description": "Fixture memory summary"} + ), ) async with contexts.database.transaction() as connection: stored = await contexts.repositories.sources.get(connection, "project", created.sources[0]) diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index fcaeef4336..9d3743b7ad 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -134,7 +134,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected _settings, _embedding, runner = _source(tmp_path, release=release) plan = runner.plan() assert plan.source_revision == expected - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004") + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") @pytest.mark.parametrize(("vectors", "code"), [(False, False), (True, False), (False, True), (True, True)]) diff --git a/tests/builtin/runtime/test_family_processing.py b/tests/builtin/runtime/test_family_processing.py index 47e0ace88e..63d1762b66 100644 --- a/tests/builtin/runtime/test_family_processing.py +++ b/tests/builtin/runtime/test_family_processing.py @@ -75,7 +75,9 @@ async def generate(self, request): return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(source.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", text=text, evidence_ids=(source.evidence_id,), description="Extracted fixture fact" + ) for source in request.evidence if (text := memory_source_text(source)) is not None ) diff --git a/tests/builtin/runtime/test_memory_window_recovery.py b/tests/builtin/runtime/test_memory_window_recovery.py index f4e25ff051..ed14c30a24 100644 --- a/tests/builtin/runtime/test_memory_window_recovery.py +++ b/tests/builtin/runtime/test_memory_window_recovery.py @@ -50,7 +50,9 @@ async def generate(self, request): return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(source.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", text=text, evidence_ids=(source.evidence_id,), description="Extracted fixture fact" + ) for source in request.evidence if (text := memory_source_text(source)) is not None ) diff --git a/tests/builtin/runtime/test_processing_composition.py b/tests/builtin/runtime/test_processing_composition.py index 514ebf5587..dd99ff9322 100644 --- a/tests/builtin/runtime/test_processing_composition.py +++ b/tests/builtin/runtime/test_processing_composition.py @@ -18,7 +18,7 @@ from contextlib import asynccontextmanager from pathlib import Path from types import SimpleNamespace -from typing import Literal, cast +from typing import Any, Literal, cast from unittest.mock import AsyncMock import pytest @@ -357,3 +357,48 @@ async def scenario() -> None: launch.assert_not_called() asyncio.run(scenario()) + + +@pytest.mark.parametrize("missing_profile", [False, True]) +def test_injected_components_skip_unneeded_provider_initialization(tmp_path, monkeypatch, missing_profile): + from powercontext.builtin.artifacts.atomic_memory.extraction import AtomicMemoryGenerationPipeline + from powercontext.builtin.inference import character_token_estimator + from tests.builtin.runtime.test_decision_composition import _FakeDecisionModel + + async def open_provider(*args, **kwargs): + raise RuntimeError("default provider initialization requested") # noqa: TRY003 + + monkeypatch.setattr(composition, "_open_pydantic_ai_model", open_provider) + component = cast(Any, SimpleNamespace(supports_atomic_memory=True)) + pipeline = AtomicMemoryGenerationPipeline( + extractor=component, reconciler=component, estimator=character_token_estimator() + ) + config = BuiltinConfig( + database=_sqlite(tmp_path / "injected.db"), + inference=InferenceConfig(generation_model="openai:unused"), + runtime=RuntimeConfig( + artifact_processing_families=(), memory_rerank_enabled=True, decision_assistance_enabled=True + ), + ) + + async def scenario(): + async with open_builtin_runtime( + config, + candidate_pipeline=pipeline, + profile_generator=None if missing_profile else component, + experience_pipeline=component, + experience_generator=component, + skill_generator=component, + handoff_pipeline=component, + memory_reranker=component, + decision_model=_FakeDecisionModel(), + scheduler_path=tmp_path / "injected.scheduler.db", + ) as runtime: + assert runtime.decision_model is not None + assert await runtime.readiness() + + if missing_profile: + with pytest.raises(RuntimeError, match="default provider initialization requested"): + asyncio.run(scenario()) + else: + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_recall_manifest_kind.py b/tests/builtin/runtime/test_recall_manifest_kind.py new file mode 100644 index 0000000000..c3933ea35e --- /dev/null +++ b/tests/builtin/runtime/test_recall_manifest_kind.py @@ -0,0 +1,65 @@ +# 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. + +"""Manifest filtering over real SQLite columns with nonbinary defaults.""" + +import asyncio + +import pytest +from sqlalchemy import Engine, event + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts + + +@pytest.mark.parametrize("collation", ["NOCASE", "RTRIM"]) +def test_manifest_kind_is_exact_with_a_nonbinary_column(collation): + def nonbinary_kind(connection, cursor, statement, parameters, context, executemany): + # Simulate case folding or space padding, not native OceanBase. + if statement.lstrip().startswith("CREATE TABLE pc_atomic_memory_current"): + statement = statement.replace("kind TEXT NOT NULL", f"kind TEXT COLLATE {collation} NOT NULL") + return statement, parameters + + async def scenario(): + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + await contexts.get("manifest-kind") + expected = {} + kinds = ("fact", "Fact", "fact ", "fact", "café", "café", "事实") + for kind in kinds: + created = await contexts.records.create_artifact( + "manifest-kind", + "atomic-memory", + ArtifactWrite(content={"kind": kind, "text": "A stored fact.", "description": "A fixture summary"}), + ) + expected.setdefault(kind, set()).add(created.artifact_id) + memories = contexts.atomic_memory.for_scope("manifest-kind") + assert len((await memories.manifest()).items) == len(kinds) + for kind in (*expected, "fAcT"): + received = set() + cursor = None + while True: + page = await memories.manifest(kind=kind, limit=1, cursor=cursor) + assert all(item.kind == kind for item in page.items) + received.update(item.ref.artifact_id for item in page.items) + cursor = page.next_cursor + if cursor is None: + break + assert received == expected.get(kind, set()) + + event.listen(Engine, "before_cursor_execute", nonbinary_kind, retval=True) + try: + asyncio.run(scenario()) + finally: + event.remove(Engine, "before_cursor_execute", nonbinary_kind) diff --git a/tests/builtin/runtime/test_statistics.py b/tests/builtin/runtime/test_statistics.py index 0452dc05cd..1093b2b614 100644 --- a/tests/builtin/runtime/test_statistics.py +++ b/tests/builtin/runtime/test_statistics.py @@ -79,7 +79,14 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR payload = evidence.content text = payload.get("content", payload.get("text")) if isinstance(payload, dict) else payload if isinstance(text, str): - candidates.append(AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(evidence.evidence_id,))) + candidates.append( + AtomicMemoryCandidate( + kind="fact", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) + ) return GenerationResult(output=AtomicMemoryExtractionOutput(candidates=tuple(candidates))) @@ -91,7 +98,9 @@ async def generate( output=AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind=request.proposal.kind, text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Reconciled fixture fact" + ), evidence_ids=request.proposal.evidence_ids, reason="Preserve each independent fixture fact with its exact Source evidence.", ) @@ -123,7 +132,9 @@ async def scenario() -> None: assert runtime.atomic_memory is not None await runtime.atomic_memory.for_scope(scope_id).flush() await runtime.atomic_memory.for_scope(scope_id).create(( - AtomicMemoryContent(kind="project_note", text="Keep kinds open."), + AtomicMemoryContent( + kind="project_note", text="Keep kinds open.", description="Explicit fixture memory" + ), )) candidate = await runtime.experience.for_scope(scope_id).propose( ProposeExperienceRequest( @@ -258,7 +269,9 @@ async def scenario() -> None: for scope_id in (root.scope_id, child.scope_id, other.scope_id): assert runtime.atomic_memory is not None await runtime.atomic_memory.for_scope(scope_id).create(( - AtomicMemoryContent(kind="fact", text=f"Fact for {scope_id}."), + AtomicMemoryContent( + kind="fact", text=f"Fact for {scope_id}.", description="Explicit fixture memory" + ), )) all_statistics = await runtime.statistics.overview( @@ -542,7 +555,9 @@ async def _seed_selection(runtime: BuiltinRuntime, count: int, /) -> tuple[str, assert runtime.atomic_memory is not None await runtime.atomic_memory.for_scope(scope_id).create( tuple( - AtomicMemoryContent(kind="fact", text=f"Scope {index} keeps fact {entry}.") + AtomicMemoryContent( + kind="fact", text=f"Scope {index} keeps fact {entry}.", description="Explicit fixture memory" + ) for entry in range(index + 1) ) ) diff --git a/tests/builtin/runtime/test_worker_prompt_usage.py b/tests/builtin/runtime/test_worker_prompt_usage.py index 0389bd4153..addb447188 100644 --- a/tests/builtin/runtime/test_worker_prompt_usage.py +++ b/tests/builtin/runtime/test_worker_prompt_usage.py @@ -91,6 +91,7 @@ def do_POST(self) -> None: "content": { "kind": request["proposal"]["kind"], "text": request["proposal"]["text"], + "description": "Spawned Worker validation preference", }, "evidence_ids": request["proposal"]["evidence_ids"], "reason": "Retain this independent fixture preference.", @@ -101,6 +102,7 @@ def do_POST(self) -> None: { "kind": "preference", "text": "The user requires spawned Worker validation.", + "description": "Spawned Worker validation preference", "evidence_ids": [item["evidence_id"] for item in request["evidence"]], } ] diff --git a/tests/builtin/test_processing_diagnostics.py b/tests/builtin/test_processing_diagnostics.py index c71925f936..ce456183f8 100644 --- a/tests/builtin/test_processing_diagnostics.py +++ b/tests/builtin/test_processing_diagnostics.py @@ -343,18 +343,26 @@ def test_memory_extraction_rules_attach_business_validation_diagnostics() -> Non assert diagnostics.failure_location == "memory-extract" -def test_atomic_memory_content_validation_attaches_business_validation_diagnostics() -> None: - class EmptyCandidateGenerator: +@pytest.mark.parametrize( + ("text", "description"), + [("", "A summary"), ("A fact", " "), ("A fact", "First\nsecond")], +) +def test_atomic_memory_content_validation_attaches_business_validation_diagnostics(text: str, description: str) -> None: + class InvalidCandidateGenerator: async def generate(self, value): del value return GenerationResult( output=AtomicMemoryExtractionOutput( - candidates=(AtomicMemoryCandidate(kind="fact", text="", evidence_ids=("evidence-1",)),) + candidates=( + AtomicMemoryCandidate( + kind="fact", text=text, description=description, evidence_ids=("evidence-1",) + ), + ) ) ) pipeline = AtomicMemoryGenerationPipeline( - extractor=EmptyCandidateGenerator(), + extractor=InvalidCandidateGenerator(), reconciler=cast(Any, None), estimator=character_token_estimator(), ) diff --git a/tests/e2e/artifact_dream_real.py b/tests/e2e/artifact_dream_real.py index 17694c5fd5..ec45bb6547 100644 --- a/tests/e2e/artifact_dream_real.py +++ b/tests/e2e/artifact_dream_real.py @@ -147,7 +147,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="working_note", text=rendering, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="working_note", + text=rendering, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) for evidence in request.evidence if (text := memory_source_text(evidence)) is not None for rendering in ( diff --git a/tests/e2e/atomic_memory_models.py b/tests/e2e/atomic_memory_models.py index f53261b3b0..fab3e80295 100644 --- a/tests/e2e/atomic_memory_models.py +++ b/tests/e2e/atomic_memory_models.py @@ -40,7 +40,7 @@ def respond(messages, _info) -> ModelResponse: value = { "action": "create", "compared_ids": [item["item_id"] for item in request["related"]], - "content": {"kind": proposal["kind"], "text": proposal["text"]}, + "content": {"kind": proposal["kind"], "text": proposal["text"], "description": proposal["description"]}, "evidence_ids": proposal["evidence_ids"], "reason": "Preserve the independent host workflow fact.", } @@ -50,6 +50,7 @@ def respond(messages, _info) -> ModelResponse: { "kind": kind, "text": text, + "description": "Independent workflow fact", "evidence_ids": [request["evidence"][0]["evidence_id"]], } ] diff --git a/tests/e2e/dream_support.py b/tests/e2e/dream_support.py index 02fd833a4a..bbc37258f9 100644 --- a/tests/e2e/dream_support.py +++ b/tests/e2e/dream_support.py @@ -59,7 +59,9 @@ async def generate(self, request: AtomicMemoryReconciliationInput, /): output=AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind=request.proposal.kind, text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Independent fixture fact" + ), evidence_ids=request.proposal.evidence_ids, reason="Preserve each independent fixture fact and its exact Source evidence.", ) diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py index 14b7164eab..8e554a43ae 100644 --- a/tests/e2e/test_access_control_http.py +++ b/tests/e2e/test_access_control_http.py @@ -91,7 +91,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) for evidence in request.evidence if (text := memory_source_text(evidence)) is not None ) @@ -340,7 +345,11 @@ async def scenario() -> None: scope.scope_id, CreateArtifactRequest.model_validate({ "family": "atomic-memory", - "content": {"kind": "preference", "text": "Use concise answers."}, + "content": { + "kind": "preference", + "text": "Use concise answers.", + "description": "Explicit fixture memory", + }, }), ) memory_head = await owner.get_artifact(scope.scope_id, "atomic-memory", memory.artifact_id) @@ -350,7 +359,11 @@ async def scenario() -> None: "atomic-memory", memory.artifact_id, ReplaceArtifactRequest.model_validate({ - "content": {"kind": "preference", "text": "Use concise Chinese answers."} + "content": { + "kind": "preference", + "text": "Use concise Chinese answers.", + "description": "Explicit fixture memory", + } }), expected_etag='"revision:1"', ) @@ -807,7 +820,11 @@ async def scenario() -> None: source = await owner.create_source(scope.scope_id, CreateSourceRequest(content="Tag access evidence")) content = { "profile": {"content": "# Profile\n\nTest before release."}, - "atomic-memory": {"kind": "fact", "text": "Private release rule"}, + "atomic-memory": { + "kind": "fact", + "text": "Private release rule", + "description": "Explicit fixture memory", + }, "experience": { "situation": "Release", "action": "Test", diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index 68d5e92be3..1db40a14c4 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -189,6 +189,7 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR kind="working_note", text="The replay test passed without duplicate writes.", evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", ) for evidence in request.evidence ) @@ -552,7 +553,9 @@ async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamP reason="The correction changes one current Memory entry.", intent="correct", proposal=AtomicMemoryContent( - kind="task_record", text="The corrected replay did create a duplicate; avoid blind retries." + kind="task_record", + text="The corrected replay did create a duplicate; avoid blind retries.", + description="Explicit fixture memory", ), evidence_ids=evidence, ), @@ -588,7 +591,7 @@ async def scenario() -> None: GetCandidateRequest(candidate_id=completed.candidate.candidate_id) ) transport_proposal = candidate_response(candidate).proposal - from powercontext.http import AtomicMemoryWriteContent as TransportAtomicMemoryContent + from powercontext.http import AtomicMemoryReadContent as TransportAtomicMemoryContent assert isinstance(transport_proposal, TransportAtomicMemoryContent) assert candidate.audit.dream_run_id == run.run_id @@ -606,7 +609,13 @@ async def scenario() -> None: "atomic-memory", citation.artifact_id, f'"revision:{citation.revision}"', - ArtifactWrite(content={"kind": "working_note", "text": "A newer manual correction."}), + ArtifactWrite( + content={ + "kind": "working_note", + "text": "A newer manual correction.", + "description": "Explicit fixture memory", + } + ), ) current = await runtime.atomic_memory.for_scope(scope).get(citation.artifact_id) expected_error = EvidenceResolutionError if target_change == "forgotten" else RevisionConflictError @@ -1219,7 +1228,9 @@ async def seed(runtime: BuiltinRuntime): assert runtime.atomic_memory is not None await runtime.atomic_memory.for_scope(scope.scope_id).flush() await runtime.atomic_memory.for_scope(scope.scope_id).create(( - AtomicMemoryContent(kind="private_note", text="UNSELECTED_SIBLING_SENTINEL"), + AtomicMemoryContent( + kind="private_note", text="UNSELECTED_SIBLING_SENTINEL", description="Explicit fixture memory" + ), )) entries = await runtime.atomic_memory.for_scope(scope.scope_id).list() citation = next(item.ref for item in entries.items if item.artifact.content.kind == "working_note") @@ -2210,7 +2221,11 @@ async def scenario() -> None: inputs = (await memories.list()).items merged = await memories.merge( tuple(item.as_read() for item in inputs), - AtomicMemoryContent(kind="working_note", text="Replaying the write key preserved exactly one row."), + AtomicMemoryContent( + kind="working_note", + text="Replaying the write key preserved exactly one row.", + description="Explicit fixture memory", + ), ) target = merged.primary.ref if revise_memory: @@ -2219,7 +2234,13 @@ async def scenario() -> None: "atomic-memory", target.artifact_id, '"revision:1"', - ArtifactWrite(content={"kind": "working_note", "text": "The key replay remained idempotent."}), + ArtifactWrite( + content={ + "kind": "working_note", + "text": "The key replay remained idempotent.", + "description": "Explicit fixture memory", + } + ), ) target = (await memories.get(updated.artifact_id)).ref for item in inputs: @@ -2324,6 +2345,7 @@ async def scenario() -> None: AtomicMemoryContent( kind="preference", text="I prefer retrying writes without checking the previous result.", + description="Explicit fixture memory", ), )) citation = (await runtime.atomic_memory.for_scope(scope).list()).items[0].ref @@ -2453,7 +2475,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /): return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="working_note", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="working_note", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) for evidence in request.evidence for text in ( "Replaying the original key kept one row.", @@ -3115,7 +3142,11 @@ async def scenario() -> None: # noqa: C901 - both servers share the controlled ): scope, source, target = await seed(first) candidate = await first._review(scope).propose_memory_dream( - AtomicMemoryContent(kind="task_record", text="Verify the committed result before retrying."), + AtomicMemoryContent( + kind="task_record", + text="Verify the committed result before retrying.", + description="Explicit fixture memory", + ), sources=(source,), artifacts=(target,), target=target, @@ -3125,7 +3156,11 @@ async def scenario() -> None: # noqa: C901 - both servers share the controlled competing = None if winner == "advance": competing = await first._review(scope).propose_memory_dream( - AtomicMemoryContent(kind="task_record", text="A separate correction supersedes the target."), + AtomicMemoryContent( + kind="task_record", + text="A separate correction supersedes the target.", + description="Explicit fixture memory", + ), sources=(source,), artifacts=(target,), target=target, diff --git a/tests/e2e/test_artifact_tags.py b/tests/e2e/test_artifact_tags.py index f69e9e4066..8f4eca4f8c 100644 --- a/tests/e2e/test_artifact_tags.py +++ b/tests/e2e/test_artifact_tags.py @@ -443,3 +443,52 @@ async def exercise_tag_http(app, *, token: str | None = None) -> str: ) assert cleared.tag_set.tags == [] return scope + + +@pytest.mark.parametrize("operation", ["manifest", "list", "search"]) +def test_atomic_http_tag_filters_unwrap_transport_values(tmp_path, operation): + from tests.e2e.test_access_control_regressions import _scope, _server + + async def scenario(): + async with _server(tmp_path) as (_, client, _): + scope = await _scope(client) + identifiers = [] + for number, tags in enumerate((["release", "客户A"], ["release"], [])): + created = await client.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "atomic-memory", + "content": {"kind": "fact", "text": f"Release checklist item {number}."}, + }, + ) + assert created.status_code == 201, created.text + identifier = created.json()["artifact_id"] + identifiers.append(identifier) + path = f"/v1/scopes/{scope}/artifacts/atomic-memory/{identifier}/tags" + current = await client.get(path) + tagged = await client.put(path, json={"tags": tags}, headers={"If-Match": current.headers["ETag"]}) + assert tagged.status_code == 200, tagged.text + path = "/v1/recall/manifest" if operation == "manifest" else f"/v1/atomic-memory/{operation}" + base = {"scope_id": scope} + if operation == "search": + base.update(query="checklist", mode="text") + cases = ( + ({}, set(identifiers)), + ({"tags": ["RELEASE"]}, set(identifiers[:2])), + ({"tags": ["release", "客户A"], "tag_match": "all"}, {identifiers[0]}), + ({"tags": ["客户A", "missing"], "tag_match": "any"}, {identifiers[0]}), + ({"tags": ["missing"]}, set()), + ) + for filters, expected in cases: + response = await client.post(path, json={**base, **filters}) + assert response.status_code == 200, response.text + body = response.json() + if operation == "manifest": + actual = {item["ref"]["artifact_id"] for item in body["items"]} + elif operation == "list": + actual = {item["artifact"]["artifact_id"] for item in body["items"]} + else: + actual = {item["memory"]["artifact"]["artifact_id"] for item in body["hits"]} + assert actual == expected, (operation, filters, body) + + asyncio.run(scenario()) diff --git a/tests/e2e/test_atomic_artifact_search_http.py b/tests/e2e/test_atomic_artifact_search_http.py index 5b90442ec4..e503def882 100644 --- a/tests/e2e/test_atomic_artifact_search_http.py +++ b/tests/e2e/test_atomic_artifact_search_http.py @@ -206,6 +206,7 @@ async def scenario() -> None: "schema": "powercontext.atomic-memory.v1", "kind": "decision", "text": "Release rollback uses a reviewed plan.", + "description": "Release rollback uses a reviewed plan.", "creation": None, } assert "scores" not in plain_item diff --git a/tests/e2e/test_atomic_memory_artifact_search.py b/tests/e2e/test_atomic_memory_artifact_search.py index 80b08cb717..b7af15ae43 100644 --- a/tests/e2e/test_atomic_memory_artifact_search.py +++ b/tests/e2e/test_atomic_memory_artifact_search.py @@ -139,6 +139,7 @@ async def scenario(): "schema": "powercontext.atomic-memory.v1", "kind": "preference", "text": "Current atomic body.", + "description": "Current atomic body.", "creation": None, } for request in ({"query": "obsolete"}, {"query": "current", "filters": {"kind": "fact"}}): diff --git a/tests/e2e/test_atomic_memory_description_upgrade.py b/tests/e2e/test_atomic_memory_description_upgrade.py new file mode 100644 index 0000000000..235d3aa032 --- /dev/null +++ b/tests/e2e/test_atomic_memory_description_upgrade.py @@ -0,0 +1,105 @@ +# 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. + +"""Nonempty released storage upgrades before description metadata exists.""" + +import json +import sqlite3 +import struct +from contextlib import closing +from hashlib import sha256 + +import pytest +import sqlite_vec + +from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemoryContent +from powercontext.builtin.artifacts.memory.canonical import canonical_json +from powercontext.builtin.artifacts.search import analyze_text +from powercontext.builtin.persistence.atomic_memory_index import ( + atomic_memory_embedding_input, + atomic_memory_embedding_input_hash, + atomic_memory_profile_fingerprint, +) +from powercontext.builtin.persistence.migrations.models import MigrationError +from tests.builtin.persistence.test_release140_server_migrations import _apply, _source + + +@pytest.mark.parametrize("release", ["1.3.0", "1.4.0"]) +@pytest.mark.parametrize("vectors", [False, True]) +def test_nonempty_released_memory_upgrades_without_rewriting_history(tmp_path, release, vectors): + _, profile, runner = _source(tmp_path, release=release, vectors=vectors) + expected = {} + with closing(sqlite3.connect(runner.database)) as connection, connection: + if vectors: + connection.enable_load_extension(True) + try: + sqlite_vec.load(connection) + finally: + connection.enable_load_extension(False) + assert "description" not in { + row[1] for row in connection.execute("PRAGMA table_info(pc_atomic_memory_current)") + } + for number in range(2): + identifier = f"historical-{number}" + content = AtomicMemoryContent(kind="fact", text=f"Preserve historical nebula fact {number}.") + payload = content.model_dump_json(by_alias=True).encode() + assert "description" not in json.loads(payload) + expected[identifier] = payload + connection.execute( + "INSERT INTO pc_artifacts (scope_id,family,artifact_id,revision,content) " + "VALUES ('scope','atomic-memory',?,1,?)", + (identifier, payload), + ) + connection.execute( + "INSERT INTO pc_artifact_heads (scope_id,family,artifact_id,revision) " + "VALUES ('scope','atomic-memory',?,1)", + (identifier,), + ) + connection.execute( + "INSERT INTO pc_atomic_memory_current " + "(scope_id,artifact_id,revision,state_version,content_hash,projection_format,kind,text," + "searchable_text,tag_keys,embedding,profile_fingerprint,embedding_input_hash) " + "VALUES ('scope',?,1,0,?,'powercontext.atomic-memory.current.v1',?,?,?,?,?,?,?)", + ( + identifier, + sha256(canonical_json(json.loads(payload))).hexdigest(), + content.kind, + content.text, + analyze_text(atomic_memory_embedding_input(content.kind, content.text)), + "[]", + None if profile is None else struct.pack("<3f", 1.0, 0.0, 0.0), + None if profile is None else atomic_memory_profile_fingerprint(profile), + None if profile is None else atomic_memory_embedding_input_hash(content.kind, content.text), + ), + ) + + result = _apply(runner) + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_005" + verified = runner.verify() + assert verified.state == "ready" and verified.readiness_scope == "server" + assert not _apply(runner).changed + with closing(sqlite3.connect(runner.database)) as connection: + assert ( + dict(connection.execute("SELECT artifact_id,content FROM pc_artifacts WHERE family='atomic-memory'")) + == expected + ) + assert connection.execute( + "SELECT artifact_id,revision,description FROM pc_atomic_memory_current ORDER BY artifact_id" + ).fetchall() == [(identifier, 1, None) for identifier in sorted(expected)] + connection.execute("UPDATE pc_atomic_memory_current SET description='Invented summary'") + connection.commit() + before = runner.database.read_bytes() + with pytest.raises(MigrationError, match="projection_verification_failed"): + runner.verify() + assert runner.database.read_bytes() == before diff --git a/tests/e2e/test_atomic_memory_flush_concurrency.py b/tests/e2e/test_atomic_memory_flush_concurrency.py index ee66cf9063..c6cdb453ec 100644 --- a/tests/e2e/test_atomic_memory_flush_concurrency.py +++ b/tests/e2e/test_atomic_memory_flush_concurrency.py @@ -59,7 +59,9 @@ async def generate(self, request: AtomicMemoryExtractionInput, /): return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(item.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", text=text, description="Rollout budget", evidence_ids=(item.evidence_id,) + ) for item in request.evidence if (text := memory_source_text(item)) is not None ) @@ -83,7 +85,9 @@ async def generate(self, request: AtomicMemoryReconciliationInput, /): action="revise", compared_ids=(target.item_id,), target_ids=(target.item_id,), - content=AtomicMemoryReconciliationContent(kind="fact", text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind="fact", text=request.proposal.text, description="Updated rollout budget" + ), evidence_ids=(*request.proposal.evidence_ids, *target.evidence_ids), reason="Update the supplied rollout budget using the new Source evidence.", ) diff --git a/tests/e2e/test_atomic_memory_generation_merge.py b/tests/e2e/test_atomic_memory_generation_merge.py index 3e3c040785..9be4014fa5 100644 --- a/tests/e2e/test_atomic_memory_generation_merge.py +++ b/tests/e2e/test_atomic_memory_generation_merge.py @@ -59,6 +59,7 @@ async def generate(self, request: AtomicMemoryExtractionInput, /): kind="constraint", text=POLICY if text == CONSOLIDATION else text, evidence_ids=(item.evidence_id,), + description="Extracted fixture fact", ) for item in request.evidence if (text := memory_source_text(item)) is not None @@ -88,7 +89,9 @@ async def generate(self, request: AtomicMemoryReconciliationInput, /): action="merge" if merge else "create", compared_ids=tuple(item.item_id for item in request.related), target_ids=tuple(item.item_id for item in inputs) if merge else (), - content=AtomicMemoryReconciliationContent(kind=request.proposal.kind, text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Reconciled fixture fact" + ), evidence_ids=evidence_ids, reason="Combine the two existing rollout rules with the consolidation Source evidence." if merge @@ -102,7 +105,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /): return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text.split("\n", 1)[0], evidence_ids=(item.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", + text=text.split("\n", 1)[0], + evidence_ids=(item.evidence_id,), + description="Extracted fixture fact", + ) for item in request.evidence if (text := memory_source_text(item)) is not None ) @@ -122,7 +130,9 @@ async def generate(self, request: AtomicMemoryReconciliationInput, /): action="create" if target is None else "merge" if request.proposal.original_refs else "revise", compared_ids=tuple(item.item_id for item in request.related), target_ids=() if target is None else (target.item_id,), - content=AtomicMemoryReconciliationContent(kind="fact", text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind="fact", text=request.proposal.text, description="Reconciled fixture fact" + ), evidence_ids=request.proposal.evidence_ids if target is None else (*request.proposal.evidence_ids, *target.evidence_ids), @@ -138,7 +148,9 @@ async def generate(self, request: AtomicMemoryReconciliationInput, /): output=AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind="constraint", text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind="constraint", text=request.proposal.text, description="Reconciled fixture fact" + ), evidence_ids=tuple(item.evidence_id for item in request.evidence), reason="Keep the independent precheck rule with supporting budget facts without consuming them.", ) @@ -292,8 +304,16 @@ async def scenario() -> None: originals = await contexts.records.create_atomic_memories( SCOPE, ( - {"kind": "fact", "text": "Database rollout budget is 1 units."}, - {"kind": "fact", "text": "Database rollout budget is 2 units."}, + { + "kind": "fact", + "text": "Database rollout budget is 1 units.", + "description": "Database rollout budget: one unit", + }, + { + "kind": "fact", + "text": "Database rollout budget is 2 units.", + "description": "Database rollout budget: two units", + }, ), ) published = {record.ref.artifact_id: record for record in (await memory.list()).items} @@ -343,8 +363,16 @@ async def scenario() -> None: await contexts.records.create_atomic_memories( SCOPE, ( - {"kind": "fact", "text": "Database rollout budget is 1 units."}, - {"kind": "fact", "text": "Database rollout budget is 2 units."}, + { + "kind": "fact", + "text": "Database rollout budget is 1 units.", + "description": "Database rollout budget: one unit", + }, + { + "kind": "fact", + "text": "Database rollout budget is 2 units.", + "description": "Database rollout budget: two units", + }, ), ) originals = (await memory.list()).items diff --git a/tests/e2e/test_atomic_memory_migration_evidence.py b/tests/e2e/test_atomic_memory_migration_evidence.py index fc8d949ea9..d4fa1237c0 100644 --- a/tests/e2e/test_atomic_memory_migration_evidence.py +++ b/tests/e2e/test_atomic_memory_migration_evidence.py @@ -323,11 +323,12 @@ async def scenario() -> None: memory = contexts.atomic_memory.for_scope(SCOPE) alpha = await memory.get(_atomic("alpha", 2).artifact_id) (created,) = await contexts.records.create_atomic_memories( - SCOPE, ({"kind": "fact", "text": "Another standalone fact."},) + SCOPE, ({"kind": "fact", "text": "Another standalone fact.", "description": "Explicit fixture memory"},) ) other = await memory.get(created.artifact_id) merged = await memory.merge( - (alpha.as_read(), other.as_read()), AtomicMemoryContent(kind="fact", text="Merged task evidence.") + (alpha.as_read(), other.as_read()), + AtomicMemoryContent(kind="fact", text="Merged task evidence.", description="Explicit fixture memory"), ) async with contexts.database.transaction() as connection: resolved = await _resolver(contexts).resolve(connection, artifacts=(merged.primary.ref,)) @@ -417,7 +418,8 @@ async def scenario() -> None: alpha = await memory.get(_atomic("alpha", 2).artifact_id) beta = await memory.get(_atomic("beta", 1).artifact_id) merged = await memory.merge( - (alpha.as_read(), beta.as_read()), AtomicMemoryContent(kind="fact", text="Both task facts.") + (alpha.as_read(), beta.as_read()), + AtomicMemoryContent(kind="fact", text="Both task facts.", description="Explicit fixture memory"), ) await memory.restore(merged.primary.ref.artifact_id, operation="undo_merge") async with contexts.database.transaction() as connection: @@ -654,7 +656,10 @@ async def generate(self, request): output=AtomicMemoryExtractionOutput( candidates=tuple( AtomicMemoryCandidate( - kind="fact", text="Tasks A and C were completed.", evidence_ids=(item.evidence_id,) + kind="fact", + text="Tasks A and C were completed.", + evidence_ids=(item.evidence_id,), + description="Extracted fixture fact", ) for item in request.evidence if item.source_ref.source_id == "migration-trigger" @@ -683,7 +688,9 @@ async def generate(self, request): compared_ids=tuple(item.item_id for item in request.related), target_ids=() if related is None else (related.item_id,), content=( - AtomicMemoryReconciliationContent(kind="fact", text=request.proposal.text) + AtomicMemoryReconciliationContent( + kind="fact", text=request.proposal.text, description="Reconciled fixture fact" + ) if related is None else None ), diff --git a/tests/e2e/test_atomic_memory_prepare_lifecycle.py b/tests/e2e/test_atomic_memory_prepare_lifecycle.py index c1c0b01318..2655c95bda 100644 --- a/tests/e2e/test_atomic_memory_prepare_lifecycle.py +++ b/tests/e2e/test_atomic_memory_prepare_lifecycle.py @@ -32,7 +32,10 @@ async def _remember(client, scope_id, text): - response = await client.post("/v1/memory/remember", json={"scope_id": scope_id, "kind": "fact", "text": text}) + response = await client.post( + "/v1/memory/remember", + json={"scope_id": scope_id, "kind": "fact", "text": text, "description": "Fixture memory summary"}, + ) assert response.status_code == 200, response.text return response.json()["records"][0] @@ -54,7 +57,9 @@ async def _change(client, scope_id, record, operation): response = await client.put( f"/v1/scopes/{scope_id}/artifacts/atomic-memory/{record['artifact']['artifact_id']}", headers={"If-Match": '"revision:1"'}, - json={"content": {"kind": "fact", "text": "Alpha replacement body."}}, + json={ + "content": {"kind": "fact", "text": "Alpha replacement body.", "description": "Fixture memory summary"} + }, ) elif operation == "forget": response = await client.post( @@ -74,7 +79,11 @@ async def _change(client, scope_id, record, operation): "inputs": [ {"artifact": item["artifact"], "state_version": item["state_version"]} for item in (record, other) ], - "content": {"kind": "fact", "text": "Merged replacement body."}, + "content": { + "kind": "fact", + "text": "Merged replacement body.", + "description": "Fixture memory summary", + }, }, ) assert response.status_code == 200, response.text diff --git a/tests/e2e/test_atomic_memory_read_snapshot.py b/tests/e2e/test_atomic_memory_read_snapshot.py index 94c22a7a65..9a680f4ecf 100644 --- a/tests/e2e/test_atomic_memory_read_snapshot.py +++ b/tests/e2e/test_atomic_memory_read_snapshot.py @@ -74,7 +74,8 @@ async def scenario() -> None: await contexts.get("project") memory = contexts.atomic_memory.for_scope("project") (created,) = await contexts.records.create_atomic_memories( - "project", ({"kind": "fact", "text": "Alpha beta original body."},) + "project", + ({"kind": "fact", "text": "Alpha beta original body.", "description": "Original alpha beta fact"},), ) index = contexts.atomic_memory.index original_search = index.search @@ -91,7 +92,13 @@ async def interleaved_search(connection, scope_id, request): "atomic-memory", created.artifact_id, '"revision:1"', - ArtifactWrite(content={"kind": "fact", "text": "Alpha beta revised body."}), + ArtifactWrite( + content={ + "kind": "fact", + "text": "Alpha beta revised body.", + "description": "Revised alpha beta fact", + } + ), ) ) assert revised.revision == 2 @@ -120,7 +127,8 @@ async def scenario() -> None: with pytest.raises(RuntimeError, match="abort outer write"): async with contexts.database.transaction(): (created,) = await contexts.records.create_atomic_memories( - "project", ({"kind": "fact", "text": "Alpha pending write."},) + "project", + ({"kind": "fact", "text": "Alpha pending write.", "description": "Pending alpha fact"},), ) record = await memory.get(created.artifact_id) assert record.artifact.revision == 1 @@ -146,7 +154,7 @@ async def scenario() -> None: await contexts.get("project") memory = contexts.atomic_memory.for_scope("project") (created,) = await contexts.records.create_atomic_memories( - "project", ({"kind": "fact", "text": "Original body."},) + "project", ({"kind": "fact", "text": "Original body.", "description": "Original fact"},) ) repository = contexts.atomic_memory.service.artifacts read_method = "latest" if operation == "get" else "decode_many" @@ -165,7 +173,9 @@ async def publish() -> None: "atomic-memory", created.artifact_id, '"revision:1"', - ArtifactWrite(content={"kind": "fact", "text": "Revised body."}), + ArtifactWrite( + content={"kind": "fact", "text": "Revised body.", "description": "Revised fact"} + ), ) await memory.forget(created.artifact_id, expected_revision=2, expected_state_version=0) diff --git a/tests/e2e/test_atomic_memory_reconciliation_retry.py b/tests/e2e/test_atomic_memory_reconciliation_retry.py index 5602358140..a2f50c6341 100644 --- a/tests/e2e/test_atomic_memory_reconciliation_retry.py +++ b/tests/e2e/test_atomic_memory_reconciliation_retry.py @@ -56,15 +56,29 @@ async def respond(self, messages: list[ModelMessage], _info: AgentInfo) -> Model evidence = value["evidence"][0]["evidence_id"] output = { "candidates": [ - {"kind": "decision", "text": "Billing uses PostgreSQL 16.", "evidence_ids": [evidence]}, - {"kind": "decision", "text": "MySQL is no longer an option.", "evidence_ids": [evidence]}, + { + "kind": "decision", + "text": "Billing uses PostgreSQL 16.", + "description": "Billing database is PostgreSQL 16", + "evidence_ids": [evidence], + }, + { + "kind": "decision", + "text": "MySQL is no longer an option.", + "description": "MySQL is excluded", + "evidence_ids": [evidence], + }, ] } return _json(output) related = [item["item_id"] for item in value["related"]] evidence = value["proposal"]["evidence_ids"] if not related: - content = {"kind": "decision", "text": value["proposal"]["text"]} + content = { + "kind": "decision", + "text": value["proposal"]["text"], + "description": value["proposal"]["description"], + } return _json({ "action": "create", "compared_ids": [], @@ -79,7 +93,7 @@ async def respond(self, messages: list[ModelMessage], _info: AgentInfo) -> Model "action": action, "compared_ids": related, "target_ids": related[:1], - "content": {"kind": "decision", "text": merged}, + "content": {"kind": "decision", "text": merged, "description": "PostgreSQL selected; MySQL excluded"}, "evidence_ids": evidence, "reason": "Same decision.", }) diff --git a/tests/e2e/test_atomic_memory_related_embedding.py b/tests/e2e/test_atomic_memory_related_embedding.py index 20ff74d954..9cc433a71f 100644 --- a/tests/e2e/test_atomic_memory_related_embedding.py +++ b/tests/e2e/test_atomic_memory_related_embedding.py @@ -82,7 +82,12 @@ async def generate(self, request): return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Fixture memory summary", + ) for evidence in request.evidence if (text := memory_source_text(evidence)) is not None ) @@ -108,7 +113,9 @@ async def generate(self, request): action="revise" if request.related else "create", compared_ids=compared, target_ids=(request.related[0].item_id,) if request.related else (), - content=AtomicMemoryReconciliationContent(kind="fact", text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind="fact", text=request.proposal.text, description="Fixture memory summary" + ), evidence_ids=request.proposal.evidence_ids, reason="Retain the preference and its supplied Source evidence.", ) @@ -177,7 +184,9 @@ async def generate(self, request): output=AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind="fact", text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind="fact", text=request.proposal.text, description="Fixture memory summary" + ), evidence_ids=request.proposal.evidence_ids, reason="Retain the independent Source fact after comparing the permitted owned memories.", ) @@ -229,6 +238,9 @@ async def scenario(): memory = contexts.atomic_memory.for_scope("project") records = await memory.get_many(ids, context=execution) assert tuple(record.artifact.artifact_id for record in records) == ids + requested = (ids[2], ids[0], ids[2], ids[1]) + reordered = await memory.get_many(requested, context=execution) + assert tuple(record.artifact.artifact_id for record in reordered) == requested custom_provider.denied = ( AccessAction.ARTIFACT_READ, ResourceRef.artifact("project", family="atomic-memory", artifact_id=ids[1]), @@ -409,17 +421,24 @@ async def scenario(): # Exceed both a comparison batch and ordinary search's default topK. owned = await contexts.records.create_atomic_memories( "project", - tuple({"kind": "fact", "text": f"Permitted owned rollout fact {number}."} for number in range(23)), + tuple( + { + "kind": "fact", + "text": f"Permitted owned rollout fact {number}.", + "description": "Fixture memory summary", + } + for number in range(23) + ), execution_context=execution, ) (denied,) = await contexts.records.create_atomic_memories( "project", - ({"kind": "fact", "text": "DENIED_OWNED_MATCH_SENTINEL"},), + ({"kind": "fact", "text": "DENIED_OWNED_MATCH_SENTINEL", "description": "Fixture memory summary"},), execution_context=execution, ) await contexts.records.create_atomic_memories( "project", - ({"kind": "fact", "text": "FOREIGN_OWNER_MATCH_SENTINEL"},), + ({"kind": "fact", "text": "FOREIGN_OWNER_MATCH_SENTINEL", "description": "Fixture memory summary"},), execution_context=foreign, ) await context.triggers.flush(limit=100) @@ -476,7 +495,7 @@ async def scenario(): execution, provider = await _configured_flush_context(contexts, owner) (original,) = await contexts.records.create_atomic_memories( "project", - ({"kind": "fact", "text": ORIGINAL},), + ({"kind": "fact", "text": ORIGINAL, "description": "Fixture memory summary"},), execution_context=execution, ) await context.triggers.flush(limit=100) diff --git a/tests/e2e/test_atomic_memory_supervisor_generation.py b/tests/e2e/test_atomic_memory_supervisor_generation.py index dcadda850b..de9461769f 100644 --- a/tests/e2e/test_atomic_memory_supervisor_generation.py +++ b/tests/e2e/test_atomic_memory_supervisor_generation.py @@ -51,7 +51,11 @@ def do_POST(self) -> None: output = { "action": "create", "compared_ids": [item["item_id"] for item in request["related"]], - "content": {"kind": request["proposal"]["kind"], "text": request["proposal"]["text"]}, + "content": { + "kind": request["proposal"]["kind"], + "text": request["proposal"]["text"], + "description": "Spawned worker acceptance testing preference", + }, "evidence_ids": request["proposal"]["evidence_ids"], "reason": "Keep the preference with the supplied Source evidence.", } @@ -61,6 +65,7 @@ def do_POST(self) -> None: { "kind": "preference", "text": _MEMORY_TEXT, + "description": "Spawned worker acceptance testing preference", "evidence_ids": [item["evidence_id"] for item in request["evidence"]], } ] diff --git a/tests/e2e/test_atomic_memory_unrelated_reconciliation.py b/tests/e2e/test_atomic_memory_unrelated_reconciliation.py index 85eacc14cf..8d48a728d3 100644 --- a/tests/e2e/test_atomic_memory_unrelated_reconciliation.py +++ b/tests/e2e/test_atomic_memory_unrelated_reconciliation.py @@ -48,6 +48,7 @@ async def generate(self, request: AtomicMemoryExtractionInput, /): { "kind": "decision", "text": "The billing service uses PostgreSQL 16.", + "description": "Billing database is PostgreSQL 16", "evidence_ids": [request.evidence[0].evidence_id], } ] @@ -66,7 +67,9 @@ async def generate(self, request: AtomicMemoryReconciliationInput, /): action="create", compared_ids=(), content=AtomicMemoryReconciliationContent( - kind=request.proposal.kind, text="Billing runs on PostgreSQL 16." + kind=request.proposal.kind, + text="Billing runs on PostgreSQL 16.", + description="Billing runs on PostgreSQL 16", ), evidence_ids=request.proposal.evidence_ids, reason="An injected policy rewrites every candidate.", @@ -129,9 +132,15 @@ def respond(messages, _info) -> ModelResponse: { "kind": "decision", "text": "The billing service uses PostgreSQL 16.", + "description": "Billing database is PostgreSQL 16", + "evidence_ids": [evidence_id], + }, + { + "kind": "fact", + "text": "The billing team had lunch today.", + "description": "Billing team lunch today", "evidence_ids": [evidence_id], }, - {"kind": "fact", "text": "The billing team had lunch today.", "evidence_ids": [evidence_id]}, ] } return ModelResponse(parts=[TextPart(json.dumps(value))]) diff --git a/tests/e2e/test_builtin_runtime.py b/tests/e2e/test_builtin_runtime.py index 20945ba73c..9d492a669a 100644 --- a/tests/e2e/test_builtin_runtime.py +++ b/tests/e2e/test_builtin_runtime.py @@ -55,7 +55,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) for evidence in request.evidence if (text := memory_source_text(evidence)) is not None ) @@ -102,7 +107,9 @@ async def scenario() -> None: memories = runtime.atomic_memory.for_scope(scope.scope_id) contents = tuple(AtomicMemoryContent(kind="fact", text=f"Fact {index}") for index in range(3)) created = await memories.create(contents) - assert tuple(record.artifact.content for record in created.records) == contents + assert tuple(record.artifact.content for record in created.records) == tuple( + content.model_copy(update={"description": content.text}) for content in contents + ) assert tuple(record.artifact.revision for record in created.records) == (1, 1, 1) assert all(len(record.artifact.lineage.sources) == 1 for record in created.records) for record in created.records: @@ -353,10 +360,14 @@ async def scenario() -> None: ) assert runtime.atomic_memory is not None shared_memory = await runtime.atomic_memory.for_scope(shared.scope_id).create(( - AtomicMemoryContent(kind="fact", text="Shared direct context evidence."), + AtomicMemoryContent( + kind="fact", text="Shared direct context evidence.", description="Explicit fixture memory" + ), )) await runtime.atomic_memory.for_scope(middle.scope_id).create(( - AtomicMemoryContent(kind="fact", text="Middle reverse-only evidence."), + AtomicMemoryContent( + kind="fact", text="Middle reverse-only evidence.", description="Explicit fixture memory" + ), )) direct = await runtime.context.for_scope(middle.scope_id).prepare( @@ -417,12 +428,18 @@ async def scenario() -> None: assert runtime.atomic_memory is not None await runtime.atomic_memory.for_scope(reader.scope_id).create( tuple( - AtomicMemoryContent(kind="fact", text=f"Candidate saturation local evidence {index}.") + AtomicMemoryContent( + kind="fact", + text=f"Candidate saturation local evidence {index}.", + description="Explicit fixture memory", + ) for index in range(16) ) ) shared_memory = await runtime.atomic_memory.for_scope(shared.scope_id).create(( - AtomicMemoryContent(kind="fact", text="Candidate saturation shared evidence."), + AtomicMemoryContent( + kind="fact", text="Candidate saturation shared evidence.", description="Explicit fixture memory" + ), )) prepared = await runtime.context.for_scope(reader.scope_id).prepare( @@ -469,7 +486,7 @@ async def scenario() -> None: ) assert runtime.atomic_memory is not None await runtime.atomic_memory.for_scope(scope.scope_id).create(( - AtomicMemoryContent(kind="fact", text="Disabled gate evidence."), + AtomicMemoryContent(kind="fact", text="Disabled gate evidence.", description="Explicit fixture memory"), )) request = PrepareContextRequest(query="disabled gate evidence") application = runtime.context.for_scope(scope.scope_id) @@ -494,7 +511,9 @@ async def scenario() -> None: ) assert runtime.atomic_memory is not None memory = runtime.atomic_memory.for_scope(scope.scope_id) - await memory.create((AtomicMemoryContent(kind="fact", text="Parallel search fact."),)) + await memory.create(( + AtomicMemoryContent(kind="fact", text="Parallel search fact.", description="Explicit fixture memory"), + )) first = asyncio.create_task(memory.search("parallel", mode="text", limit=1)) second = asyncio.create_task(memory.search("parallel", mode="text", limit=1)) @@ -511,7 +530,15 @@ async def scenario() -> None: with pytest.raises(RuntimeError, match="abort outer write"): async with contexts.database.transaction(): created = await contexts.records.create_artifact( - "project", "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": "Uncommitted fact"}) + "project", + "atomic-memory", + ArtifactWrite( + content={ + "kind": "fact", + "text": "Uncommitted fact", + "description": "Explicit fixture memory", + } + ), ) loaded = await contexts.atomic_memory.for_scope("project").get(created.artifact_id) assert loaded.artifact.content.text == "Uncommitted fact" diff --git a/tests/e2e/test_extraction_diagnostics.py b/tests/e2e/test_extraction_diagnostics.py index 31d88f32dd..3de6275d62 100644 --- a/tests/e2e/test_extraction_diagnostics.py +++ b/tests/e2e/test_extraction_diagnostics.py @@ -52,7 +52,11 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR assert isinstance(evidence.content, dict) text = evidence.content["content"] assert isinstance(text, str) - candidates.append(AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(evidence.evidence_id,))) + candidates.append( + AtomicMemoryCandidate( + kind="fact", text=text, evidence_ids=(evidence.evidence_id,), description="Extracted fixture fact" + ) + ) return GenerationResult(output=AtomicMemoryExtractionOutput(candidates=tuple(candidates))) @@ -64,7 +68,9 @@ async def generate( output=AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind=request.proposal.kind, text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Reconciled fixture fact" + ), evidence_ids=request.proposal.evidence_ids, reason="Keep the captured decision.", ) diff --git a/tests/e2e/test_kimi_code_host.py b/tests/e2e/test_kimi_code_host.py index db574e9ed9..7f159e0cbc 100644 --- a/tests/e2e/test_kimi_code_host.py +++ b/tests/e2e/test_kimi_code_host.py @@ -45,6 +45,7 @@ REPOSITORY = Path(__file__).parents[2] EXPLICIT = "Explicit Kimi deployment code is amber-582." AUTOMATIC = "Kimi acceptance staging color is cobalt-731." +AUTOMATIC_DESCRIPTION = "Kimi staging color: cobalt-731." def _objects(value: Any) -> Iterator[dict[str, Any]]: @@ -188,14 +189,25 @@ def generate_memory(self, body): "action": "noop" if duplicate else "create", "compared_ids": [item["item_id"] for item in payload["related"]], "target_ids": [duplicate["item_id"]] if duplicate else [], - "content": None if duplicate else {"kind": proposal["kind"], "text": proposal["text"]}, + "content": None + if duplicate + else { + "kind": proposal["kind"], + "text": proposal["text"], + "description": proposal["description"], + }, "evidence_ids": [] if duplicate else proposal["evidence_ids"], "reason": "Synthetic evidence reconciliation", } else: value = { "candidates": [ - {"kind": "fact", "text": AUTOMATIC, "evidence_ids": [item["evidence_id"]]} + { + "kind": "fact", + "text": AUTOMATIC, + "description": AUTOMATIC_DESCRIPTION, + "evidence_ids": [item["evidence_id"]], + } for item in payload["evidence"] if AUTOMATIC in json.dumps(item["content"]) ] @@ -414,6 +426,7 @@ def invoke(prompt, token: str | None = "kimi-synthetic-server-token", *, cwd: Pa f"{artifact['artifact_id']}/revisions/{artifact['revision']}" ) revision.raise_for_status() + assert revision.json()["content"]["description"] == AUTOMATIC_DESCRIPTION evidence_ids = {item["source_id"] for item in revision.json()["sources"]} assert evidence_ids & {item["source_id"] for item in captured} start = len(gateway.requests) diff --git a/tests/e2e/test_locomo_scope_resume.py b/tests/e2e/test_locomo_scope_resume.py index 600f5d9096..05bae6a784 100644 --- a/tests/e2e/test_locomo_scope_resume.py +++ b/tests/e2e/test_locomo_scope_resume.py @@ -55,7 +55,11 @@ def test_locomo_ingestion_registers_and_resumes_its_scope_after_restart( ) async def respond(messages, info): - prompt = next(part.content for part in messages[-1].parts if part.part_kind == "user-prompt") + prompt = next( + part.content for message in reversed(messages) for part in message.parts if part.part_kind == "user-prompt" + ) + if prompt == "Reply with one token.": + return ModelResponse(parts=[TextPart("OK")]) value = json.loads(prompt) if "evidence" in value and "proposal" not in value: output = { @@ -63,6 +67,7 @@ async def respond(messages, info): { "kind": "fact", "text": "Caroline went to the LGBTQ support group on 7 May 2023.", + "description": "Caroline's support group visit", "evidence_ids": [value["evidence"][0]["evidence_id"]], } ] @@ -72,7 +77,11 @@ async def respond(messages, info): output = { "action": "create", "compared_ids": [item["item_id"] for item in value["related"]], - "content": {"kind": proposal["kind"], "text": proposal["text"]}, + "content": { + "kind": proposal["kind"], + "text": proposal["text"], + "description": "Caroline's support group visit", + }, "evidence_ids": proposal["evidence_ids"], "reason": "Preserve the supplied session evidence.", } diff --git a/tests/e2e/test_mcp_transport.py b/tests/e2e/test_mcp_transport.py index b1569460f8..2cac9b2765 100644 --- a/tests/e2e/test_mcp_transport.py +++ b/tests/e2e/test_mcp_transport.py @@ -247,6 +247,7 @@ async def exercise_tools() -> set[str]: "get_handoff_report", "get_scope", "get_memory_entry", + "get_recall_manifest", "get_topic_memory", "handoff_current_work", "create_dream_run", diff --git a/tests/e2e/test_memory_search_concurrency.py b/tests/e2e/test_memory_search_concurrency.py index b34101061e..e1d33456fc 100644 --- a/tests/e2e/test_memory_search_concurrency.py +++ b/tests/e2e/test_memory_search_concurrency.py @@ -35,8 +35,10 @@ from powercontext.builtin.inference import EmbeddingResult, InferenceUsage from powercontext.builtin.persistence.oceanbase import OceanBaseConfig from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.runtime import ( BuiltinConfig, + RuntimeConfig, open_builtin_runtime, ) from powercontext.builtin.scope import ScopeDraft @@ -249,3 +251,53 @@ def _database_config( return OceanBaseConfig(url=SecretStr(url)) return SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / f'memory-search-{mode}.db'}") + + +@pytest.mark.parametrize("operation", ["create", "create_artifact", "replace", "merge", "restore"]) +def test_explicit_memory_writes_complete_while_generation_capacity_is_occupied(operation: str) -> None: + async def scenario() -> None: + reranker = _PausingReranker() + async with open_builtin_runtime( + BuiltinConfig(runtime=RuntimeConfig(generation_concurrency=1)), memory_reranker=reranker + ) as runtime: + assert runtime.scopes is not None and runtime.atomic_memory is not None + scope = await runtime.scopes.create( + ScopeDraft(title="Explicit writes", summary="Independent of generation", idempotency_key="writes") + ) + memory = runtime.atomic_memory.for_scope(scope.scope_id) + records = runtime.records.for_scope(scope.scope_id) + initial = await memory.create(( + AtomicMemoryContent(kind="fact", text="Stable searchable fact."), + AtomicMemoryContent(kind="fact", text="Another stable fact."), + )) + original = initial.records[0] + if operation == "restore": + await memory.forget(original.ref.artifact_id, expected_revision=1, expected_state_version=0) + searching = asyncio.create_task(memory.search("stable", mode="text", limit=1)) + try: + await asyncio.wait_for(reranker.paused.wait(), timeout=TIMEOUT_SECONDS) + content = AtomicMemoryContent(kind="fact", text="Explicitly saved fact.") + write = ArtifactWrite(content=content.model_dump(mode="json", exclude_none=True)) + if operation == "create": + pending = memory.create((content,)) + elif operation == "create_artifact": + pending = records.create_artifact("atomic-memory", write) + elif operation == "replace": + pending = records.replace_artifact("atomic-memory", original.ref.artifact_id, '"revision:1"', write) + elif operation == "merge": + pending = memory.merge(tuple(item.as_read() for item in initial.records), content) + else: + pending = memory.restore(original.ref.artifact_id) + await asyncio.wait_for(pending, timeout=2) + assert not searching.done() + current = (await memory.list()).items + assert any( + item.artifact.content.text + == (original.artifact.content.text if operation == "restore" else content.text) + for item in current + ) + finally: + reranker.resume.set() + await asyncio.wait_for(searching, timeout=TIMEOUT_SECONDS) + + asyncio.run(scenario()) diff --git a/tests/e2e/test_observability.py b/tests/e2e/test_observability.py index 94105b532d..2691b68079 100644 --- a/tests/e2e/test_observability.py +++ b/tests/e2e/test_observability.py @@ -385,7 +385,12 @@ def test_database_failure_log_does_not_include_memory_content(caplog, tmp_path) with caplog.at_level(logging.ERROR, logger="powercontext.server.app"): response = client.post( "/v1/memory/remember", - json={"scope_id": scope_id, "kind": "fact", "text": memory_content}, + json={ + "scope_id": scope_id, + "kind": "fact", + "text": memory_content, + "description": "Explicit fixture memory", + }, ) records = [ @@ -454,6 +459,7 @@ def test_memory_write_stage_spans_are_bounded_and_nested(monkeypatch, tmp_path) "kind": "decision", "text": private_memory_content, "evidence_ids": ["source:1"], + "description": "Explicit fixture memory", } ] }) @@ -468,7 +474,11 @@ def _request(self, messages, model_settings, model_request_parameters): model.custom_output_text = json.dumps({ "action": "create", "compared_ids": [], - "content": {"kind": "decision", "text": private_memory_content}, + "content": { + "kind": "decision", + "text": private_memory_content, + "description": "Explicit fixture memory", + }, "evidence_ids": ["source:1"], "reason": "Preserve the exact fixture Source.", }) @@ -756,7 +766,12 @@ def test_memory_read_stage_spans_are_bounded_and_nested(monkeypatch, tmp_path) - ) remembered = client.post( "/v1/memory/remember", - json={"scope_id": scope_id, "kind": "fact", "text": memory_content}, + json={ + "scope_id": scope_id, + "kind": "fact", + "text": memory_content, + "description": "Explicit fixture memory", + }, ) searched = client.post( "/v1/memory/search", @@ -1035,7 +1050,10 @@ async def generate(self, request: AtomicMemoryExtractionInput, /): output=AtomicMemoryExtractionOutput( candidates=( AtomicMemoryCandidate( - kind="fact", text=self._text, evidence_ids=tuple(item.evidence_id for item in request.evidence) + kind="fact", + text=self._text, + evidence_ids=tuple(item.evidence_id for item in request.evidence), + description="Extracted fixture fact", ), ) ) @@ -1266,7 +1284,12 @@ def test_vector_search_exports_embedding_under_memory_search_without_recording_t scope_id = _get_default_scope_id(client) remembered = client.post( "/v1/memory/remember", - json={"scope_id": scope_id, "kind": "fact", "text": memory_content}, + json={ + "scope_id": scope_id, + "kind": "fact", + "text": memory_content, + "description": "Explicit fixture memory", + }, ) searched = client.post( "/v1/memory/search", @@ -1338,7 +1361,12 @@ def test_injected_always_on_embedding_skips_readiness_but_traces_vector_search(m scope_id = _get_default_scope_id(client) remembered = client.post( "/v1/memory/remember", - json={"scope_id": scope_id, "kind": "fact", "text": "Private injected vector memory."}, + json={ + "scope_id": scope_id, + "kind": "fact", + "text": "Private injected vector memory.", + "description": "Explicit fixture memory", + }, ) exporter.clear() searched = client.post( diff --git a/tests/e2e/test_prompt_management_api.py b/tests/e2e/test_prompt_management_api.py index 4875e23cf4..846f158c05 100644 --- a/tests/e2e/test_prompt_management_api.py +++ b/tests/e2e/test_prompt_management_api.py @@ -216,7 +216,7 @@ def respond(messages, info) -> ModelResponse: value = { "action": "create", "compared_ids": [item["item_id"] for item in request["related"]], - "content": {"kind": proposal["kind"], "text": proposal["text"]}, + "content": {"kind": proposal["kind"], "text": proposal["text"], "description": "Scoped preference"}, "evidence_ids": proposal["evidence_ids"], "reason": "Retain the independently scoped preference.", } @@ -230,6 +230,7 @@ def respond(messages, info) -> ModelResponse: { "kind": "preference", "text": text, + "description": "Scoped preference", "evidence_ids": [request["evidence"][0]["evidence_id"]], } ] diff --git a/tests/e2e/test_pydantic_ai_chain.py b/tests/e2e/test_pydantic_ai_chain.py index 75c4dd9e70..9a0ddc7bbc 100644 --- a/tests/e2e/test_pydantic_ai_chain.py +++ b/tests/e2e/test_pydantic_ai_chain.py @@ -47,7 +47,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="working_note", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="working_note", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) for evidence in request.evidence if (text := memory_source_text(evidence)) is not None if isinstance(evidence.source_metadata, dict) diff --git a/tests/e2e/test_recall_embedding_timeout.py b/tests/e2e/test_recall_embedding_timeout.py index 3a533bd699..693047b413 100644 --- a/tests/e2e/test_recall_embedding_timeout.py +++ b/tests/e2e/test_recall_embedding_timeout.py @@ -205,6 +205,31 @@ async def scenario() -> Any: _assert_fallback_record(record, expected) +@pytest.mark.parametrize( + ("error", "expected"), + [ + (InferenceUnavailableError("embed"), "inference_unavailable"), + (InferenceTimeoutError("embed", 1.0), "inference_timeout"), + ], +) +def test_atomic_search_returns_the_logged_embedding_fallback_reason( + tmp_path: Path, caplog: pytest.LogCaptureFixture, error: Exception, expected: str +) -> None: + async def scenario() -> Any: + async with _runtime(tmp_path / "search.db", 5.0, _FailingQueryEmbedding(error)) as runtime: + scope_id = await _scope_with_memory(runtime, "search-fallback") + assert runtime.atomic_memory is not None + return await runtime.atomic_memory.for_scope(scope_id).search(_QUERY, mode="auto") + + page = asyncio.run(scenario()) + + assert page.mode == "text" + assert page.hits + assert page.embedding_fallback == expected + (record,) = _fallbacks(caplog) + _assert_fallback_record(record, expected) + + def test_one_budget_bounds_the_embedding_waits_of_a_preparation_across_scopes( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/e2e/test_recall_manifest.py b/tests/e2e/test_recall_manifest.py new file mode 100644 index 0000000000..de74312421 --- /dev/null +++ b/tests/e2e/test_recall_manifest.py @@ -0,0 +1,423 @@ +# 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. + +"""Metadata-only bounded recall over real SQLite storage.""" + +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import Engine, event + +from tests.e2e.test_access_control_regressions import _grant, _scope, _server + + +def test_manifest_is_bounded_metadata_and_refs_are_readable(tmp_path): + async def scenario(): + async with _server(tmp_path) as (app, client, _): + scope = await _scope(client) + empty = await client.post("/v1/recall/manifest", json={"scope_id": scope}) + assert empty.status_code == 200, empty.text + assert empty.json() == {"items": [], "next_cursor": None, "code": "empty_inventory"} + for number in range(3): + response = await client.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "atomic-memory", + "content": { + "kind": "decision", + "text": f"Full private body {number}", + "description": f"Summary {number}", + }, + }, + ) + assert response.status_code == 201, response.text + prepare_request = { + "scope_id": scope, + "query": "private body", + "assembly": {"sections": [{"family": "memory", "limit": 8}]}, + } + prepared_before = await client.post("/v1/context/prepare", json=prepare_request) + assert prepared_before.status_code == 200, prepared_before.text + statements = [] + + def capture(connection, cursor, statement, parameters, context, executemany): + statements.append(statement.lower()) + + event.listen(Engine, "before_cursor_execute", capture) + try: + response = await client.post("/v1/recall/manifest", json={"scope_id": scope, "limit": 2}) + finally: + event.remove(Engine, "before_cursor_execute", capture) + assert any("pc_atomic_memory_current.description" in sql for sql in statements) + assert not any( + forbidden in sql + for sql in statements + for forbidden in ("pc_artifacts", "searchable_text", "pc_atomic_memory_current.text", ".embedding") + ) + assert response.status_code == 200, response.text + first = response.json() + assert first["code"] == "page" + assert len(first["items"]) == 2 + assert first["next_cursor"] + assert "Full private body" not in response.text + assert first["items"][0]["description"].startswith("Summary") + import httpx + from fastmcp import Client + from fastmcp.client.transports import StreamableHttpTransport + + from powercontext.server.mcp import create_mcp_server + + mcp_app = create_mcp_server(app).http_app(path="/") + app.mount("/manifest-test-mcp", mcp_app) + + def http_client_factory(headers=None, timeout=None, auth=None, **kwargs): + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + headers=headers, + timeout=timeout, + auth=auth, + follow_redirects=True, + ) + + transport = StreamableHttpTransport( + "http://test/manifest-test-mcp/", + httpx_client_factory=http_client_factory, + ) + async with mcp_app.lifespan(mcp_app), Client(transport) as mcp: + via_mcp = await mcp.call_tool("get_recall_manifest", {"scope_id": scope, "limit": 2}) + assert via_mcp.structured_content["items"] == first["items"] + assert via_mcp.structured_content["code"] == "page" + second = await client.post( + "/v1/recall/manifest", + json={ + "scope_id": scope, + "limit": 2, + "cursor": first["next_cursor"], + }, + ) + assert second.status_code == 200, second.text + assert len(second.json()["items"]) == 1 + assert second.json()["next_cursor"] is None + refs = [item["ref"]["artifact_id"] for item in first["items"] + second.json()["items"]] + assert len(set(refs)) == 3 + ref = first["items"][0]["ref"] + exact = await client.get( + f"/v1/scopes/{scope}/artifacts/atomic-memory/{ref['artifact_id']}/revisions/{ref['revision']}" + ) + assert exact.status_code == 200, exact.text + assert exact.json()["content"]["description"] == first["items"][0]["description"] + changed_filter = await client.post( + "/v1/recall/manifest", + json={ + "scope_id": scope, + "kind": "fact", + "cursor": first["next_cursor"], + }, + ) + assert changed_filter.status_code == 400, changed_filter.text + denied = await client.post( + "/v1/recall/manifest", json={"scope_id": scope}, headers={"Authorization": "Bearer stranger"} + ) + assert denied.status_code == 403, denied.text + bad = await client.post("/v1/recall/manifest", json={"scope_id": scope, "limit": 101}) + assert bad.status_code == 422 + assert bad.json()["error"]["code"] + prepared_after = await client.post("/v1/context/prepare", json=prepare_request) + assert prepared_after.status_code == 200, prepared_after.text + assert prepared_after.content == prepared_before.content + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("configured_model", [False, True]) +def test_explicit_writes_preserve_descriptions_without_model_requests(tmp_path, monkeypatch, configured_model): + from pydantic_ai.messages import ModelResponse, TextPart + from pydantic_ai.models.function import FunctionModel + + from powercontext.builtin.runtime import InferenceConfig, composition + + writing = False + + def unexpected_generation(messages, info): + if writing: + pytest.fail("Explicit memory writes must not request model generation") + return ModelResponse(parts=[TextPart("OK")]) + + model = FunctionModel(unexpected_generation) + + async def open_local_model(*args, **kwargs): + return model, model + + monkeypatch.setattr(composition, "_open_pydantic_ai_model", open_local_model) + + async def scenario(): + nonlocal writing + inference = InferenceConfig(generation_model="test") if configured_model else InferenceConfig() + async with _server(tmp_path, inference=inference) as (app, client, _): + writing = True + scope = await _scope(client) + created = await client.post( + "/v1/memory/remember", + json={ + "scope_id": scope, + "kind": "decision", + "text": "Use JWT for login.", + }, + ) + assert created.status_code == 200, created.text + manifest = await client.post("/v1/recall/manifest", json={"scope_id": scope}) + assert manifest.status_code == 200, manifest.text + entry = manifest.json()["items"][0] + assert entry["description"] == "Use JWT for login." + ref = entry["ref"] + path = f"/v1/scopes/{scope}/artifacts/atomic-memory/{ref['artifact_id']}" + current = await client.get(path) + revised = await client.put( + path, + headers={"If-Match": current.headers["ETag"]}, + json={ + "content": {"kind": "decision", "text": "Use a shorter JWT expiry."}, + }, + ) + assert revised.status_code == 200, revised.text + assert revised.json()["content"]["description"] == "Use a shorter JWT expiry." + assert revised.json()["content"]["text"] == "Use a shorter JWT expiry." + old = await client.get(path + "/revisions/1") + assert old.status_code == 200 + assert old.json()["content"]["text"] == "Use JWT for login." + assert old.json()["content"]["description"] == "Use JWT for login." + stale = await client.put( + path, + headers={"If-Match": current.headers["ETag"]}, + json={"content": {"kind": "decision", "text": "Stale change"}}, + ) + assert stale.status_code in {409, 412}, stale.text + + other = await client.post( + "/v1/memory/remember", + json={ + "scope_id": scope, + "kind": "decision", + "text": "Keep refresh tokens.", + "description": "Refresh token policy", + }, + ) + assert other.status_code == 200, other.text + from powercontext.builtin.artifacts.atomic_memory import AtomicMemoryContent + + generic = app.state.application.artifact_merge.for_scope(scope, "atomic-memory") + first = await generic.get(ref["artifact_id"]) + second = await generic.get(other.json()["records"][0]["artifact"]["artifact_id"]) + assert second.artifact.content.description == "Refresh token policy" + merged = await generic.merge( + (first.as_read(), second.as_read()), + AtomicMemoryContent(kind="decision", text="Use JWT and retain refresh tokens."), + ) + assert merged.primary.artifact.content.description == "Use JWT and retain refresh tokens." + final_manifest = await client.post("/v1/recall/manifest", json={"scope_id": scope}) + assert len(final_manifest.json()["items"]) == 1 + assert final_manifest.json()["items"][0]["description"] == "Use JWT and retain refresh tokens." + + asyncio.run(scenario()) + + +def test_projection_rebuild_copies_authoritative_description(tmp_path): + import sqlite3 + + from powercontext.builtin.runtime.atomic_memory_rebuild import rebuild_atomic_memory_projection + + async def scenario(): + async with _server(tmp_path) as (app, client, _): + scope = await _scope(client) + created = await client.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "atomic-memory", + "content": { + "kind": "fact", + "text": "Historical authoritative body", + "description": "Stored summary", + }, + }, + ) + assert created.status_code == 201, created.text + identifier = created.json()["artifact_id"] + exact_path = f"/v1/scopes/{scope}/artifacts/atomic-memory/{identifier}/revisions/1" + before = await client.get(exact_path) + assert before.status_code == 200 + with sqlite3.connect(tmp_path / "regressions.db") as connection: + connection.execute("UPDATE pc_atomic_memory_current SET description = NULL") + manifest = await client.post("/v1/recall/manifest", json={"scope_id": scope}) + assert manifest.status_code == 200, manifest.text + assert manifest.json()["items"][0]["description"] is None + application = app.state.application.atomic_memory._application + report = await rebuild_atomic_memory_projection( + application.database, + application.index, + maintenance_confirmed=True, + ) + assert report.ready, report + rebuilt = await client.post("/v1/recall/manifest", json={"scope_id": scope}) + assert rebuilt.json()["items"][0]["description"] == "Stored summary" + after = await client.get(exact_path) + assert after.content == before.content + async with _server(tmp_path) as (_, client, _): + manifest = await client.post("/v1/recall/manifest", json={"scope_id": scope}) + assert manifest.json()["items"][0]["description"] == "Stored summary" + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + ("登录采用 JWT。\n\t刷新令牌有效期为 7 天。", "登录采用 JWT。 刷新令牌有效期为 7 天。"), + ("记忆" * 120, "记忆" * 99 + "记…"), + ("x" * 200, "x" * 200), + ], + ids=["multiline", "truncated-unicode", "exact-limit"], +) +def test_default_description_is_a_bounded_single_line_persisted_on_write(tmp_path, body, expected): + async def scenario(): + async with _server(tmp_path) as (_, client, _): + scope = await _scope(client) + created = await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "atomic-memory", "content": {"kind": "fact", "text": body}}, + ) + assert created.status_code == 201, created.text + identifier = created.json()["artifact_id"] + async with _server(tmp_path) as (_, client, _): + exact = await client.get(f"/v1/scopes/{scope}/artifacts/atomic-memory/{identifier}/revisions/1") + assert exact.status_code == 200, exact.text + assert exact.json()["content"]["description"] == expected + assert exact.json()["content"]["text"] == body + manifest = await client.post("/v1/recall/manifest", json={"scope_id": scope}) + assert manifest.json()["items"][0]["description"] == expected + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("description", [" ", "first\nsecond", "x" * 201]) +def test_invalid_supplied_description_is_rejected(tmp_path, description): + async def scenario(): + async with _server(tmp_path) as (_, client, _): + scope = await _scope(client) + response = await client.post( + "/v1/memory/remember", + json={"scope_id": scope, "kind": "fact", "text": "A new fact", "description": description}, + ) + assert response.status_code in {400, 422}, response.text + manifest = await client.post("/v1/recall/manifest", json={"scope_id": scope}) + assert manifest.json()["items"] == [] + + asyncio.run(scenario()) + + +def test_manifest_cursor_rejects_a_different_unicode_kind(tmp_path): + async def scenario(): + async with _server(tmp_path) as (_, client, _): + scope = await _scope(client) + kind = "café" + for number in range(2): + created = await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "atomic-memory", "content": {"kind": kind, "text": f"Rule {number}."}}, + ) + assert created.status_code == 201, created.text + request = {"scope_id": scope, "kind": kind, "limit": 1} + first = await client.post("/v1/recall/manifest", json=request) + assert first.status_code == 200, first.text + cursor = first.json()["next_cursor"] + assert cursor is not None + second = await client.post("/v1/recall/manifest", json={**request, "cursor": cursor}) + assert second.status_code == 200, second.text + assert len(second.json()["items"]) == 1 + assert second.json()["items"][0]["ref"] != first.json()["items"][0]["ref"] + assert second.json()["next_cursor"] is None + different_kind = "cafe\u0301" + empty = await client.post("/v1/recall/manifest", json={**request, "kind": different_kind}) + assert empty.status_code == 200, empty.text + assert empty.json()["code"] == "empty_inventory" + rejected = await client.post( + "/v1/recall/manifest", json={**request, "kind": different_kind, "cursor": cursor} + ) + assert rejected.status_code == 400, rejected.text + assert rejected.json()["error"]["code"] == "invalid_cursor" + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("long_tags", [False, True], ids=["short-tag", "max-unicode-tags"]) +def test_manifest_cursors_round_trip_with_maximum_filters(tmp_path, long_tags): + from powercontext.http import RecallManifestRequest + + async def scenario(): + async with _server(tmp_path) as (_, client, _): + scope = await _scope(client) + tags = ["界" * 63 + chr(97 + index) for index in range(16)] if long_tags else ["release"] + kind = "类" * 128 + identifiers = set() + for number in range(2): + created = await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "atomic-memory", "content": {"kind": kind, "text": f"Release rule {number}."}}, + ) + assert created.status_code == 201, created.text + identifier = created.json()["artifact_id"] + identifiers.add(identifier) + path = f"/v1/scopes/{scope}/artifacts/atomic-memory/{identifier}/tags" + current = await client.get(path) + tagged = await client.put(path, json={"tags": tags[:1]}, headers={"If-Match": current.headers["ETag"]}) + assert tagged.status_code == 200, tagged.text + request = {"scope_id": scope, "kind": kind, "tags": tags, "tag_match": "any", "limit": 1} + first = await client.post("/v1/recall/manifest", json=request) + assert first.status_code == 200, first.text + cursor = first.json()["next_cursor"] + assert cursor is not None and len(cursor) <= 4096 + next_request = RecallManifestRequest.model_validate({**request, "cursor": cursor}) + second = await client.post( + "/v1/recall/manifest", json=next_request.model_dump(mode="json", exclude_none=True) + ) + assert second.status_code == 200, second.text + assert second.json()["next_cursor"] is None + found = [item["ref"]["artifact_id"] for item in first.json()["items"] + second.json()["items"]] + assert len(found) == len(set(found)) == 2 and set(found) == identifiers + other = await client.post( + "/v1/scopes", + json={"title": "Other scope", "summary": "Cursor isolation", "idempotency_key": "cursor-other-scope"}, + ) + assert other.status_code == 201, other.text + for changed in ( + {"tags": ["different"]}, + {"tag_match": "all"}, + {"kind": "fact"}, + {"scope_id": other.json()["scope_id"]}, + ): + rejected = await client.post("/v1/recall/manifest", json={**request, "cursor": cursor, **changed}) + assert rejected.status_code == 400, rejected.text + await _grant(client, scope, "stranger", "scope.viewer") + rejected_subject = await client.post( + "/v1/recall/manifest", json={**request, "cursor": cursor}, headers={"Authorization": "Bearer stranger"} + ) + assert rejected_subject.status_code == 400, rejected_subject.text + tampered = await client.post("/v1/recall/manifest", json={**request, "cursor": cursor + "tampered"}) + assert tampered.status_code == 400, tampered.text + wrong_endpoint = await client.post("/v1/atomic-memory/list", json={**request, "cursor": cursor}) + assert wrong_endpoint.status_code == 400, wrong_endpoint.text + + asyncio.run(scenario()) diff --git a/tests/e2e/test_runtime_server.py b/tests/e2e/test_runtime_server.py index cf49415ab1..124889956c 100644 --- a/tests/e2e/test_runtime_server.py +++ b/tests/e2e/test_runtime_server.py @@ -144,7 +144,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="decision", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="decision", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) for evidence in request.evidence if (text := memory_source_text(evidence)) is not None ) @@ -469,7 +474,9 @@ async def scenario() -> None: } assert len(scope_ids) == 5 await client.remember_memory( - RememberMemoryRequest(scope_id=writer_scope.scope_id, kind="fact", text=memory_text) + RememberMemoryRequest( + scope_id=writer_scope.scope_id, kind="fact", text=memory_text, description="Explicit fixture memory" + ) ) leaked = await client.list_memory_entries(ListMemoryEntriesRequest(scope_id=reader_scope.scope_id)) await client.remember_memory( @@ -477,6 +484,7 @@ async def scenario() -> None: scope_id=accent_scope.scope_id, kind="fact", text="Espresso is on the third floor.", + description="Explicit fixture memory", ) ) accent_leaked = await client.list_memory_entries(ListMemoryEntriesRequest(scope_id=plain_scope.scope_id)) @@ -924,7 +932,12 @@ async def scenario() -> None: client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) scope_id = (await client.get_default_scope()).scope_id remembered = await client.remember_memory( - RememberMemoryRequest(scope_id=scope_id, kind="decision", text="Use strict transport models.") + RememberMemoryRequest( + scope_id=scope_id, + kind="decision", + text="Use strict transport models.", + description="Explicit fixture memory", + ) ) assert len(remembered.records) == 1 original = remembered.records[0] @@ -937,7 +950,11 @@ async def scenario() -> None: ref.family, ref.artifact_id, ReplaceArtifactRequest.model_validate({ - "content": {"kind": "decision", "text": "Keep strict Pydantic transport models."} + "content": { + "kind": "decision", + "text": "Keep strict Pydantic transport models.", + "description": "Explicit fixture memory", + } }), expected_etag=head.headers["ETag"], ) @@ -1031,7 +1048,9 @@ async def stale_revision() -> None: ): client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) remembered = await client.remember_memory( - RememberMemoryRequest(scope_id=scope_id, kind="decision", text="first") + RememberMemoryRequest( + scope_id=scope_id, kind="decision", text="first", description="Explicit fixture memory" + ) ) with pytest.raises(ServerResponseError) as caught: await client.remember_memory( @@ -1040,6 +1059,7 @@ async def stale_revision() -> None: kind="decision", text="stale", expected_revision=remembered.records[0].artifact.revision + 1, + description="Explicit fixture memory", ) ) assert caught.value.status_code == 422 @@ -1096,7 +1116,12 @@ def test_runtime_server_rejects_non_strict_transport_values(tmp_path: Path) -> N ), transport.post( "/v1/memory/remember", - json={"scope_id": scope_id, "kind": "decision", "text": "🧠" * 3_000}, + json={ + "scope_id": scope_id, + "kind": "decision", + "text": "🧠" * 3_000, + "description": "Explicit fixture memory", + }, ), ] @@ -1114,7 +1139,12 @@ def test_runtime_server_returns_canonical_memory_error_details(tmp_path: Path, t scope_id = default_scope.json()["scope_id"] remembered = transport.post( "/v1/memory/remember", - json={"scope_id": scope_id, "kind": "decision", "text": "Keep canonical errors actionable."}, + json={ + "scope_id": scope_id, + "kind": "decision", + "text": "Keep canonical errors actionable.", + "description": "Explicit fixture memory", + }, ) remembered.raise_for_status() before = transport.post("/v1/memory/entries/list", json={"scope_id": scope_id}) @@ -1122,7 +1152,7 @@ def test_runtime_server_returns_canonical_memory_error_details(tmp_path: Path, t responses = [ transport.post( "/v1/memory/remember", - json={"scope_id": scope_id, "kind": "decision", "text": text}, + json={"scope_id": scope_id, "kind": "decision", "text": text, "description": "Explicit fixture memory"}, ), transport.post( "/v1/memory/entries/revise", @@ -1176,14 +1206,19 @@ def test_runtime_server_accepts_normalized_memory_byte_limit(tmp_path: Path, tex with TestClient(app) as transport: scope = transport.get("/v1/scopes/default") scope.raise_for_status() - payload = {"scope_id": scope.json()["scope_id"], "kind": "decision", "text": text} + payload = { + "scope_id": scope.json()["scope_id"], + "kind": "decision", + "text": text, + "description": "Explicit fixture memory", + } remembered = transport.post("/v1/memory/remember", json=payload) remembered.raise_for_status() assert remembered.json()["records"][0]["text"] == normalized revised = transport.post( "/v1/memory/entries/revise", json={ - **payload, + **{key: value for key, value in payload.items() if key != "description"}, "citation": { "memory_ref": {"family": "memory", "artifact_id": "legacy-collection", "revision": 1}, "entry_id": "legacy-entry", @@ -1205,7 +1240,13 @@ def test_runtime_server_rejects_legacy_memory_tag_targets(tmp_path: Path) -> Non scope.raise_for_status() scope_id = scope.json()["scope_id"] remembered = transport.post( - "/v1/memory/remember", json={"scope_id": scope_id, "kind": "fact", "text": "Tagged migrations."} + "/v1/memory/remember", + json={ + "scope_id": scope_id, + "kind": "fact", + "text": "Tagged migrations.", + "description": "Explicit fixture memory", + }, ) remembered.raise_for_status() artifact_id = remembered.json()["records"][0]["artifact"]["artifact_id"] @@ -1258,6 +1299,7 @@ async def invalid_create(_self: ScopedAtomicMemoryApplication, _contents: object "scope_id": default_scope.json()["scope_id"], "kind": "decision", "text": "valid text", + "description": "Explicit fixture memory", }, ) diff --git a/tests/e2e/test_topic_memory_recovery_migration.py b/tests/e2e/test_topic_memory_recovery_migration.py index 41461f7740..c0d84cc18b 100644 --- a/tests/e2e/test_topic_memory_recovery_migration.py +++ b/tests/e2e/test_topic_memory_recovery_migration.py @@ -26,6 +26,7 @@ import pytest from sqlalchemy import Engine, event +from sqlalchemy.ext.asyncio import create_async_engine from typer.testing import CliRunner from powercontext.builtin.persistence.errors import ArtifactProcessingLeadershipLostError @@ -34,7 +35,7 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.supervision import ArtifactProcessingLeaseRepository from powercontext.builtin.persistence.tables import BUILTIN_TABLES -from powercontext.builtin.persistence.topic_memory_recovery_schema import recovery_statements +from powercontext.builtin.persistence.topic_memory_recovery_schema import recovery_resource, recovery_statements from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts from powercontext.builtin.runtime.topic_memory_recovery import TopicMemoryRecoveryService @@ -196,6 +197,69 @@ async def write_and_reopen(database): asyncio.run(write_and_reopen(SQLiteConfig(url=f"sqlite+aiosqlite:///{restored}"))) +@pytest.mark.parametrize("upgrade", ["none", "startup", "offline"]) +def test_legacy_atomic_maintenance_without_description_still_initializes(tmp_path, upgrade): + path = tmp_path / "legacy-atomic.db" + baseline = recovery_resource()["atomic_maintenance_sqlite_v1"] + with closing(sqlite3.connect(path)) as connection, connection: + for kind, name, _table, ddl in baseline["schema"]: + # SQLite creates FTS shadow tables when the virtual table is created. + if kind == "table" and name.startswith("pc_atomic_memory_current_fts_"): + continue + connection.execute(ddl) + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{path}") + if upgrade == "startup": + from powercontext.builtin.persistence.atomic_memory_index import RelationalAtomicMemoryIndex + from powercontext.builtin.persistence.atomic_memory_index_schema import atomic_memory_current_table + + async def upgrade_projection(): + engine = create_async_engine(config.url) + try: + async with engine.begin() as connection: + await RelationalAtomicMemoryIndex(atomic_memory_current_table(), None).require_current_schema( + connection + ) + finally: + await engine.dispose() + + asyncio.run(upgrade_projection()) + elif upgrade == "offline": + environment = tmp_path / "maintenance.env" + environment.write_text( + f"POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_DATABASE_URL={config.url}\n", + encoding="utf-8", + ) + migrated = CliRunner().invoke( + create_cli([app]), + [ + "server", + "atomic-memory-migrate", + "--action", + "apply", + "--maintenance-confirmed", + "--env-file", + str(environment), + ], + ) + assert migrated.exit_code == 0, migrated.output + assert json.loads(migrated.output)["ready"] is True + runner = TopicMemoryRecoveryMigrationRunner(config) + assert runner.plan().state == "uninitialized" + assert _apply(runner).backup_state == "completed" + assert runner.verify().state == "ready" + + async def reopen(): + async with open_builtin_contexts(BuiltinConfig(database=config)) as contexts: + await contexts.get("scope-a") + created = await contexts.records.create_artifact( + "scope-a", "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": "Legacy continuation"}) + ) + record = await contexts.atomic_memory.for_scope("scope-a").get(created.artifact_id) + assert record.artifact.content.description == "Legacy continuation" + + asyncio.run(reopen()) + + @pytest.mark.parametrize("continuation", ["startup", "migration"]) def test_atomic_maintenance_continuation_rolls_back_before_retry(atomic_maintenance_database, continuation): path, config = atomic_maintenance_database @@ -226,12 +290,15 @@ def interrupt(_connection, _cursor, statement, _parameters, _context, _executema assert runner.verify().state == "ready" -@pytest.mark.parametrize("damage", ["column", "missing_fts", "trigger", "archive", "unknown_table"]) +@pytest.mark.parametrize("damage", ["column", "description_type", "missing_fts", "trigger", "archive", "unknown_table"]) def test_unknown_atomic_maintenance_output_is_rejected_before_writes(atomic_maintenance_database, damage): path, config = atomic_maintenance_database with closing(sqlite3.connect(path)) as connection, connection: if damage == "column": connection.execute("ALTER TABLE pc_atomic_memory_current ADD COLUMN unexpected TEXT") + elif damage == "description_type": + connection.execute("ALTER TABLE pc_atomic_memory_current DROP COLUMN description") + connection.execute("ALTER TABLE pc_atomic_memory_current ADD COLUMN description INTEGER") elif damage == "missing_fts": connection.execute("DROP TABLE pc_atomic_memory_current_fts") elif damage == "trigger": diff --git a/tests/e2e/test_zcode_service_chain.py b/tests/e2e/test_zcode_service_chain.py index b01c89ccd7..3136026eaa 100644 --- a/tests/e2e/test_zcode_service_chain.py +++ b/tests/e2e/test_zcode_service_chain.py @@ -49,7 +49,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) for evidence in request.evidence if (text := memory_source_text(evidence)) is not None ) diff --git a/tests/e2e/zcode_acceptance/protocol.py b/tests/e2e/zcode_acceptance/protocol.py index 5d1e37211b..b8f58ce62f 100644 --- a/tests/e2e/zcode_acceptance/protocol.py +++ b/tests/e2e/zcode_acceptance/protocol.py @@ -314,7 +314,13 @@ def generate_memory(self, body: dict[str, Any]) -> JSONResponse: "action": "noop" if duplicate else "create", "compared_ids": [item["item_id"] for item in related], "target_ids": [duplicate["item_id"]] if duplicate else [], - "content": None if duplicate else {"kind": proposal["kind"], "text": proposal["text"]}, + "content": None + if duplicate + else { + "kind": proposal["kind"], + "text": proposal["text"], + "description": "Synthetic project verification color", + }, "evidence_ids": [] if duplicate else proposal["evidence_ids"], "reason": "Already represented" if duplicate else "Independent synthetic source evidence", } @@ -326,7 +332,12 @@ def generate_memory(self, body: dict[str, Any]) -> JSONResponse: "\n".join(strings(item["content"])), ) if match: - candidates.append({"kind": "fact", "text": match[0], "evidence_ids": [item["evidence_id"]]}) + candidates.append({ + "kind": "fact", + "text": match[0], + "description": f"{match[1]} project verification color", + "evidence_ids": [item["evidence_id"]], + }) value = {"candidates": candidates} output = json.dumps(value) tools = body.get("tools", []) diff --git a/tests/langchain_middleware/test_middleware.py b/tests/langchain_middleware/test_middleware.py index 28bc52ebfb..fd930d8cf5 100644 --- a/tests/langchain_middleware/test_middleware.py +++ b/tests/langchain_middleware/test_middleware.py @@ -79,7 +79,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR text = payload.get("content", payload.get("text")) if isinstance(payload, dict) else payload if isinstance(text, str): candidates.append( - AtomicMemoryCandidate(kind="working_note", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="working_note", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) ) return GenerationResult(output=AtomicMemoryExtractionOutput(candidates=tuple(candidates))) @@ -92,7 +97,9 @@ async def generate( output=AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind=request.proposal.kind, text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Reconciled fixture fact" + ), evidence_ids=request.proposal.evidence_ids, reason="Keep the captured turn with its exact Source evidence.", ) @@ -190,7 +197,11 @@ def test_middleware_injects_recall_without_persisting_it(tmp_path: Path) -> None async def scenario(client: PowerContextClient) -> None: scope_id = await _default_scope_id(client) - await client.remember_memory(RememberMemoryRequest(scope_id=scope_id, kind="decision", text=MEMORY_TEXT)) + await client.remember_memory( + RememberMemoryRequest( + scope_id=scope_id, kind="decision", text=MEMORY_TEXT, description="Explicit fixture memory" + ) + ) result = await agent.ainvoke( {"messages": [HumanMessage(content="How should we deploy the application and its migrations?")]}, diff --git a/tests/native/test_oceanbase_engine_recovery.py b/tests/native/test_oceanbase_engine_recovery.py index 7f144e8f6b..0503d987f1 100644 --- a/tests/native/test_oceanbase_engine_recovery.py +++ b/tests/native/test_oceanbase_engine_recovery.py @@ -175,7 +175,7 @@ def ping(connection, _identity, _guard): _apply(_full_server_runner(backend), policy="manual") resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.revision == "v1_4_1_004" and result.readiness_scope == "server" + assert result.state == "ready" and result.revision == "v1_4_1_005" and result.readiness_scope == "server" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journal.read_text()) diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index 6d17946926..ccdea630d3 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -401,7 +401,7 @@ def test_native_public_migration_switches_an_installed_130_service( if failure in {"startup", "switch"}: assert applied.exit_code == 1, applied.output assert result["error"] == "service_start_failed" - assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_004" + assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_005" assert adapter.manager_state() in {ManagerState.INACTIVE, ManagerState.FAILED} phase = "switching" if failure == "switch" else "start_failed" assert json.loads(controller.maintenance_path.read_text())["phase"] == phase diff --git a/tests/pydantic_ai_adapter/test_toolset.py b/tests/pydantic_ai_adapter/test_toolset.py index bcccb1ca12..f41c9a5896 100644 --- a/tests/pydantic_ai_adapter/test_toolset.py +++ b/tests/pydantic_ai_adapter/test_toolset.py @@ -97,6 +97,7 @@ async def scenario() -> Any: "text": "Use the public client.", "reason": "shared contract", "expected_revision": None, + "description": None, } assert "assembly" not in client.prepare_requests[0].model_fields_set assert client.prepare_requests[0].model_dump(mode="json", exclude_unset=True) == { diff --git a/tests/release_upgrades/test_full_server.py b/tests/release_upgrades/test_full_server.py index 8f1c578d4a..371bceb2a8 100644 --- a/tests/release_upgrades/test_full_server.py +++ b/tests/release_upgrades/test_full_server.py @@ -34,11 +34,12 @@ from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from typing import TypedDict +from typing import Any, TypedDict from urllib.parse import urlsplit import httpx import pytest +from jsonschema import Draft202012Validator ROOT = Path(__file__).resolve().parents[2] TOKEN = "isolated-upgrade-acceptance-token" # noqa: S105 -- disposable fixture, never a deployment credential @@ -123,6 +124,7 @@ def do_POST(self): self.wfile.write(response) return payload = next((obj for obj in objects(body["messages"]) if "evidence" in obj), None) + value: dict[str, Any] if payload is None: value = {"ready": True} elif "proposal" in payload: @@ -146,6 +148,17 @@ def do_POST(self): if AUTOMATIC_MEMORY in json.dumps(item["content"]) ] } + # Released schemas forbid extra fields; follow the requested output + # schema instead of the target checkout's current content model. + description_requested = any( + {"kind", "text", "description"} <= obj.get("properties", {}).keys() + for schema in objects(body) + if {"candidates", "action"} & schema.get("properties", {}).keys() + for obj in objects(schema) + ) + contents = [*value.get("candidates", []), value.get("content")] + for content in (item for item in contents if description_requested and item is not None): + content["description"] = AUTOMATIC_MEMORY output = json.dumps(value) tools = body.get("tools", []) message = {"role": "assistant", "content": output} @@ -186,6 +199,72 @@ def do_POST(self): thread.join(timeout=5) +@pytest.mark.parametrize("description", [False, True], ids=["released", "current"]) +@pytest.mark.parametrize("stage", ["extract", "create", "noop"]) +@pytest.mark.parametrize("transport", ["prompted", "tool", "native"]) +def test_model_gateway_matches_requested_memory_schema(model_gateway, description, stage, transport): + properties = {"kind": {"type": "string"}, "text": {"type": "string"}} + if description: + properties["description"] = {"type": "string", "minLength": 1, "maxLength": 200} + if stage == "extract": + properties["evidence_ids"] = {"type": "array", "items": {"type": "string"}, "minItems": 1} + record = {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False} + if stage == "extract": + output_properties = {"candidates": {"type": "array", "items": {"$ref": "#/$defs/Record"}}} + else: + output_properties = { + "action": {"enum": ["create", "noop"]}, + "compared_ids": {"type": "array", "items": {"type": "string"}}, + "target_ids": {"type": "array", "items": {"type": "string"}}, + "content": {"anyOf": [{"$ref": "#/$defs/Record"}, {"type": "null"}]}, + "evidence_ids": {"type": "array", "items": {"type": "string"}}, + "reason": {"type": "string"}, + } + schema = { + "$defs": {"Record": record}, + "type": "object", + "properties": output_properties, + "required": list(output_properties), + "additionalProperties": False, + } + payload = {"evidence": [{"evidence_id": "source-1", "content": {"content": AUTOMATIC_MEMORY}}]} + if stage != "extract": + payload.update({ + "proposal": { + "kind": "fact", + "text": AUTOMATIC_MEMORY, + "description": "Input metadata must not choose the output schema", + "evidence_ids": ["source-1"], + }, + "related": [{"item_id": "existing", "text": AUTOMATIC_MEMORY}] if stage == "noop" else [], + }) + body = {"model": "upgrade-fixture", "messages": [{"role": "user", "content": json.dumps(payload)}]} + if transport == "prompted": + body["messages"].insert( + 0, {"role": "system", "content": "Respond using this JSON schema:\n" + json.dumps(schema)} + ) + elif transport == "tool": + body["tools"] = [{"type": "function", "function": {"name": "final_result", "parameters": schema}}] + else: + body["response_format"] = {"type": "json_schema", "json_schema": {"name": "final_result", "schema": schema}} + with httpx.Client(trust_env=False) as client: + response = client.post(model_gateway["url"] + "/chat/completions", json=body) + response.raise_for_status() + message = response.json()["choices"][0]["message"] + if transport == "tool": + assert message["tool_calls"][0]["function"]["name"] == "final_result" + output = json.loads(message["tool_calls"][0]["function"]["arguments"]) + else: + output = json.loads(message["content"]) + Draft202012Validator(schema).validate(output) + if stage == "noop": + assert output["action"] == "noop" and output["content"] is None and output["evidence_ids"] == [] + else: + content = output["candidates"][0] if stage == "extract" else output["content"] + assert content["text"] == AUTOMATIC_MEMORY + assert ("description" in content) == description + + def digest(value): return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() diff --git a/tests/test_locomo_plus_runner.py b/tests/test_locomo_plus_runner.py index cbd00fbd2e..abeb7c9f60 100644 --- a/tests/test_locomo_plus_runner.py +++ b/tests/test_locomo_plus_runner.py @@ -474,7 +474,12 @@ async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationR return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( - AtomicMemoryCandidate(kind="fact", text=text, evidence_ids=(evidence.evidence_id,)) + AtomicMemoryCandidate( + kind="fact", + text=text, + evidence_ids=(evidence.evidence_id,), + description="Extracted fixture fact", + ) for evidence in request.evidence if (text := memory_source_text(evidence)) is not None ) @@ -512,7 +517,9 @@ async def extract(messages, info): output = AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind=request.proposal.kind, text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Reconciled fixture fact" + ), evidence_ids=request.proposal.evidence_ids, reason="Preserve the recorded dialogue.", ).model_dump_json(by_alias=True) @@ -1521,7 +1528,9 @@ async def reconcile(messages, info): output = AtomicMemoryReconciliationOutput( action="create", compared_ids=tuple(item.item_id for item in request.related), - content=AtomicMemoryReconciliationContent(kind=request.proposal.kind, text=request.proposal.text), + content=AtomicMemoryReconciliationContent( + kind=request.proposal.kind, text=request.proposal.text, description="Reconciled fixture fact" + ), evidence_ids=request.proposal.evidence_ids, reason="Preserve each recorded dialogue with its exact Source evidence.", ) diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 68f5f77e5c..17dcdcf851 100644 --- a/tests/test_managed_atomic_maintenance.py +++ b/tests/test_managed_atomic_maintenance.py @@ -76,7 +76,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b assert inspected.exit_code == 0, inspected.output plan = json.loads(inspected.output) assert plan["source_revision"] == "s1300" - assert plan["target_revision"] == "v1_4_1_004" + assert plan["target_revision"] == "v1_4_1_005" assert plan["readiness_scope"] == "server" assert historical.read_bytes() == original @@ -103,7 +103,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b with closing(sqlite3.connect(result["backup_ref"])) as connection: assert tuple(connection.iterdump()) == original_dump with closing(sqlite3.connect(historical)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] assert connection.execute("SELECT content FROM pc_artifacts").fetchall() == [(b'{"history":"unchanged"}',)] verified = CliRunner().invoke(cli, [*arguments, "--action", "verify"]) assert verified.exit_code == 0, verified.output diff --git a/tests/test_managed_processing_maintenance.py b/tests/test_managed_processing_maintenance.py index d1d06578f0..76aa1849d9 100644 --- a/tests/test_managed_processing_maintenance.py +++ b/tests/test_managed_processing_maintenance.py @@ -75,7 +75,7 @@ def test_registered_mode_switch_backs_up_preserves_revision_and_fences_old_worke assert result.backup_state == "completed" assert result.backup_ref and Path(result.backup_ref).is_file() with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index fbc144d95b..b46b9f5659 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -112,6 +112,7 @@ async def inspect_components() -> tuple[list[str], int, int]: "replace_artifact", "get_atomic_memory_state", "list_atomic_memories", + "get_recall_manifest", "search_atomic_memory", "merge_atomic_memories", "change_atomic_memory_lifecycle", @@ -161,6 +162,7 @@ async def inspect_annotations() -> dict[str, Any]: "get_artifact_revision", "get_atomic_memory_state", "list_atomic_memories", + "get_recall_manifest", "search_atomic_memory", "preview_atomic_memory_restoration", ): diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 3db38c5731..83e3b1171b 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -59,9 +59,10 @@ def invoke(action, *options): "v1_4_1_002", "v1_4_1_003", "v1_4_1_004", + "v1_4_1_005", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_4_1_004", "name": "v1_4_1_004", "software_version": "1.4.1", "sequence": 4} + target = {"revision": "v1_4_1_005", "name": "v1_4_1_005", "software_version": "1.4.1", "sequence": 5} assert plan["target_migration"] == target applied = invoke( @@ -76,11 +77,18 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] - assert set(recorded["release_checksums"]) == {expected_source["revision"], "s1400", "s1401", "s1402", "v1_4_1_004"} + assert set(recorded["release_checksums"]) == { + expected_source["revision"], + "s1400", + "s1401", + "s1402", + "v1_4_1_004", + "v1_4_1_005", + } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target repeated = invoke("apply") diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index 7cdbb5ce2c..deda3e6d2b 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -63,7 +63,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_004" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_005" assert plan["readiness_scope"] == "server" full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) assert full.exit_code == 0, full.output diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index 37395a6544..d98e3f9ea2 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -1097,6 +1097,7 @@ def test_parallel_workers_reject_changed_preparation_inputs(parallel_reports, mo parallel.write_json(root / gate.MANIFEST, policy) elif changed == "checkout": monkeypatch.setenv("GITHUB_SHA", "another-commit") + monkeypatch.setenv("PC_UPGRADE_SOURCE_SHA", "another-commit") else: plan = json.loads((prepared / "plan.json").read_text()) plan["resolved_matrix"]["required_cases"].pop()