diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 1f8fc3201b..c5fb9d9762 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -291,6 +291,12 @@ jobs: PYTEST_ADDOPTS: --require-dsh-config-runtime --require-claude-code-python-floor run: make unit-test + - name: Check Source serialization with minimum Pydantic + if: matrix.suite == 'unit' && matrix.python-version == '3.11' + run: >- + uv run --with 'pydantic==2.12.0' python -m pytest + tests/test_source_locators.py tests/test_source_observations.py tests/test_api_contract.py -q + - name: Run the ZCode service chain with native Hook deadlines if: matrix.suite == 'e2e' # Preserve the real Hook deadlines without competing with xdist workers. diff --git a/.gitignore b/.gitignore index 6d7cca108a..5619e2ce6c 100644 --- a/.gitignore +++ b/.gitignore @@ -273,6 +273,10 @@ integrations/pi/plugins/powercontext/*.tgz # Local acceptance databases and redacted run artifacts .artifacts/ +dev-*.db +dev-*.db-journal +dev-*.db-wal +dev-*.db-shm # pnpm package store is a local cache, never committed. .pnpm-store/ diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index ae55b601cf..0e41515ebd 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -541,11 +541,12 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -`sqlite-identity-stats-7d89a97a-v1` matches the bundled API contract and qualifies -all thirteen operations, including identity, statistics and Runtime observations, -for its exact Server source and wheel above. The historical -`sqlite-observability-stats-4568b499-v1` and `sqlite-observability-4568b499-v1` records -retain their twelve-operation and eleven-operation evidence, respectively. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, +normalized OpenAPI contract digest, qualification-wheel digest and operation set. +`sqlite-locators-ccfa6f4d-v1` matches the bundled contract and qualifies its thirteen operations +for the exact Server source and wheel recorded below. Historical records retain their original +identities, operation sets and evidence. Selecting a record does not qualify another Server source. +CI fixture identities remain distinct from 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 `sqlite-atomic-112db11e-v1` record qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Separate `authenticatedClients` evidence qualifies those operations for the current client contract in Bearer mode with a valid Principal; it does not replace the original Server contract or qualify anonymous access. Historical records retain their exact identities and evidence, including the separate `sqlite-atomic-1da99747-linux-v1` qualification. 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 remain disabled unless separate authenticated-client evidence covers the current contract and selected authentication mode. 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. @@ -977,6 +978,56 @@ separate. ### Server protocol qualification records +#### Server d9686bba protocol qualification + +`sqlite-atomic-d9686bba-v1` qualifies Server sources at +`d9686bba60560ea292269cd814619c44764da60a` for OpenAPI digest +`8f04516fa14119300005366b7de26a42f5fa0a43119209b7e4654987b611aaf9`. +The wheel is `powercontext-0.1.dev1223+gd9686bba6-py3-none-any.whl`, SHA-256 +`9b0f2234c43079c7883bb6bfa4bc3156c5209cf13f230dae34418a096b21aada`. +All 622 packaged runtime files in `powercontext/` and `powercontext_service_bootstrap/` +match that commit byte for byte. The sorted runtime manifest digest is +`66b62e9527500b694fd55444176783ee3e9546bd23d23d0487283eb2d6bde53b` +(one `wheel/path sha256` line per file, with a final newline). +The OpenAPI contract, project configuration and lock also match the commit. + +On 2026-10-30, macOS 26.6.2 ARM64 with Python 3.13.12 and Rust 1.95.0 ran the existing +`desktop/tests/real_server.py` harness against this extracted wheel and independent SQLite Server +processes. All four modes passed: anonymous loopback, static Bearer, HTTPS with an explicit CA/base path, +and an injected provider with enforced access. The native `server_probe` was built from the product +modules and locked dependencies. All eleven catalogued operations, including `get_server_info`, were +exercised through discovery, explicit Scope selection, Memory save/search/exact revision reads, +identity changes, same-identity revocation, committed writes with lost responses without replay, +51 same-title Scopes across two pages, and Server survival after client exit. + +The harness ran from a temporary working directory with an isolated Python environment and explicit +fixture settings, avoiding checkout-local dotenv configuration and model inference. The report records +`desktopWorkingTreeDirty=false`; wheel identity and run evidence are retained under ignored +`desktop/.artifacts/qualification-wheel-identity.json` and `desktop/.artifacts/real-server.json`. +Reproduce from the source commit with an empty wheel output directory: + +```sh +uv sync --frozen --python 3.13 +uv build --wheel --out-dir desktop/.artifacts/server-wheel +cargo +1.95.0 build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --locked python desktop/tests/fixture_qualification.py --server-commit d9686bba60560ea292269cd814619c44764da60a +qualification_checkout="$PWD" +qualification_python="$PWD/.venv/bin/python" +qualification_workdir="$(mktemp -d)" +(cd "$qualification_workdir" && "$qualification_python" "$qualification_checkout/desktop/tests/real_server.py") +``` + +This qualifies the recorded SQLite protocol behavior. It does not qualify installed GUI workflows, +native credential persistence, Windows/Intel execution, signing, or release readiness. Historical records +retain their original evidence. The generated Rust locator decoder also has real serialization regressions +for both tagged variants, exactly one serialized tag, sequence zero, Unicode headings, and rejection of +unknown, missing or mismatched tags. + +本记录对应上述精确源码、OpenAPI 契约和 wheel;622 个运行时文件与提交逐字节一致。 +在 macOS ARM64 上,四种独立真实 SQLite/HTTP 连接模式均通过,覆盖发现接口及记录中的十一项操作。 +测试隔离本地 dotenv 配置且未调用模型推理;结果不替代安装后 GUI、原生凭据库、Windows/Intel、签名或正式发布验收。 +历史记录保留原有身份和证据。 + #### Server 112db11e protocol qualification `sqlite-atomic-112db11e-v1` qualifies Server source @@ -2464,6 +2515,46 @@ under ignored `desktop/.artifacts/real-server.json` and `desktop/.artifacts/serv This record covers local SQLite protocol behavior. It does not establish installed UI, native credential persistence, Windows execution, external production acceptance or release readiness. + +#### Server ccfa6f4d protocol qualification + +`sqlite-locators-ccfa6f4d-v1` qualifies Server source +`ccfa6f4d221f78b38c5e6a76f21535eb40100226` with OpenAPI digest +`18af205a886552b6c2d9f41fb72a52cbf4453ddc70a98a47103212b4c16f2a6f`. +The wheel is `powercontext-0.1.dev1471+gccfa6f4d2-py3-none-any.whl`, SHA-256 +`1229552d6e8054c2ee3fbb22eb143ae56ea996fa8fe2fe5873a2474b0ddf6a6c`. +All 743 packaged runtime files match the recorded Git source bytes, with no missing or extra files. +The sorted runtime manifest digest is +`a9bf3cc8f6b440283d945e3ba60cef7ed0e2f1803d9e1c9f25379bedf3088d3b` +(one `wheel/path sha256` line per file, with a final newline). + +On 2026-11-07, macOS 26.6.2 ARM64 with Python 3.13.12 ran the unchanged +`desktop/tests/real_server.py` against this extracted wheel and independent SQLite Server processes. +Anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced-access +modes passed. The thirteen catalogued operations include connection identity, Server discovery, +statistics, Scope selection, Memory save/search and exact revision reads. The harness also checks +committed writes with lost responses without replay and Server survival after client exit. The provider +mode verifies identity changes, same-identity revocation, 51 same-title Scopes and scoped authorization. + +The native probe was built from the product Cargo lock without `ci-fixtures`. Its SHA-256 is +`22218a7f37312264a9912b24dd5252edab70b77884274045e27e5ee8eef17020`. +The report records Desktop commit `ccfa6f4d221f78b38c5e6a76f21535eb40100226` and a dirty working tree +containing the added qualification record, UI test fixture and documentation updates. Runtime source +bytes match the recorded commit. Ignored run evidence is retained in `desktop/.artifacts/real-server.json`. + +Reproduce using the recorded wheel and the client containing this qualification record: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/real_server.py \ + --product-qualification sqlite-locators-ccfa6f4d-v1 \ + --wheel desktop/.artifacts/server-wheel/powercontext-0.1.dev1471+gccfa6f4d2-py3-none-any.whl +``` + +本记录绑定上述精确源码、契约和 wheel,743 个运行时文件逐字节核对一致,四种独立真实 SQLite/HTTP +连接模式全部通过。该记录验证所列协议操作;定位信息升级由单独的迁移和 HTTP/MCP 回归测试覆盖。 +它不替代安装后 GUI、原生凭据持久化、Windows/Intel、签名或 seekDB/OceanBase 升级验收。 + ## Server 63feca70 identity and statistics product qualification `sqlite-identity-stats-63feca70-v1` qualifies all thirteen Desktop operations against @@ -2506,3 +2597,36 @@ including the bundled-record Principal fence and rejected unauthenticated mode. All 195 UI tests, generated-contract checks, lint, TypeScript checking and the frontend production build pass. The native fixtures and UI tests do not establish installed-platform acceptance. + + +## Server c00dbf14 Source plugin protocol qualification + +`sqlite-source-plugins-c00dbf14-v1` qualifies all thirteen Desktop protocol operations against +Server source `c00dbf1455e1985f6a782b4d39c9ee8cde6e74b7`. + +- Normalized OpenAPI SHA-256: `3ea9f0661702254563e72d2291469b84a961730f572a32467f15458e7a979650`. +- Wheel: `powercontext-0.1.dev1509+gc00dbf145-py3-none-any.whl`. +- Wheel SHA-256: `5555c090e6efa7b2194bd643b6f95e495aec17e9ed87e55a0cbc744ab2b7c529`. +- Native debug probe SHA-256: `777c6d62d605da84e2039fb74ea1542fdb3b2d333880232643a99c877aeb8e9f`. + +All 744 packaged runtime files match the recorded Git source after CRLF normalization, with no +missing or extra runtime files. On 2026-11-07, macOS 26.6.2 ARM64 and Python 3.13.12 passed +anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and enforced Provider modes +through the ordinary product ConnectionManager. The probe was built with the product Cargo lock +without `ci-fixtures` and selected this bundled qualification. + +The unchanged `desktop/tests/real_server.py` exercised identity, discovery, exact Scope selection, +statistics, Memory save/search/revision reads, committed writes with lost responses without replay, +Principal changes, same-identity revocation, scoped authorization and 51 same-title Scopes. +The wheel and report are retained under ignored `desktop/.artifacts/source-framework-upgrade/`. +This qualifies the observed local SQLite protocol; it does not establish installed WebView, +native credential persistence, other operating systems, native database upgrades or release acceptance. + +Reproduce against the recorded artifact: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/real_server.py \ + --product-qualification sqlite-source-plugins-c00dbf14-v1 \ + --wheel desktop/.artifacts/source-framework-upgrade/powercontext-0.1.dev1509+gc00dbf145-py3-none-any.whl +``` diff --git a/desktop/scripts/generate.mjs b/desktop/scripts/generate.mjs index ad4ff9dad5..389dba9067 100644 --- a/desktop/scripts/generate.mjs +++ b/desktop/scripts/generate.mjs @@ -180,6 +180,29 @@ async function generateRustModels() { for (const name of ["ServerInfo", "ContractVersion", "FeatureContract"]) { input.components.schemas[name]["x-rust-allow-unknown-fields"] = true; } + // Child singleton enums already validate the discriminator. Let them own + // its serde encoding too: an internal tag would consume the required field + // on decode and emit it twice on encode. + for (const schema of Object.values(input.components.schemas)) { + const tag = schema.discriminator?.propertyName; + if (!tag || !schema.oneOf?.length) continue; + const tags = schema.oneOf.map((variant) => { + const child = input.components.schemas[variant.$ref?.split("/").at(-1)]; + const values = child?.properties?.[tag]?.enum; + return child?.required?.includes(tag) && + values?.length === 1 && + (!schema.discriminator.mapping || + schema.discriminator.mapping[values[0]] === variant.$ref) + ? values[0] + : undefined; + }); + if ( + tags.every((value) => typeof value === "string") && + new Set(tags).size === tags.length + ) { + schema["x-rust-untagged-discriminator"] = true; + } + } const inputPath = join(temporary, "openapi.yaml"); await writeFile(inputPath, YAML.stringify(input)); await run( diff --git a/desktop/scripts/openapi-generator/rust/model.mustache b/desktop/scripts/openapi-generator/rust/model.mustache index 27a99706ae..e289b6fffc 100644 --- a/desktop/scripts/openapi-generator/rust/model.mustache +++ b/desktop/scripts/openapi-generator/rust/model.mustache @@ -89,8 +89,9 @@ impl std::fmt::Display for {{{classname}}} { {{!-- for schemas that have a discriminator --}} {{#discriminator}} #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "{{{propertyBaseName}}}")] -pub enum {{{classname}}} { +{{/discriminator}}{{#vendorExtensions.x-rust-untagged-discriminator}}#[serde(untagged)] +{{/vendorExtensions.x-rust-untagged-discriminator}}{{^vendorExtensions.x-rust-untagged-discriminator}}{{#discriminator}}#[serde(tag = "{{{propertyBaseName}}}")] +{{/discriminator}}{{/vendorExtensions.x-rust-untagged-discriminator}}{{#discriminator}}pub enum {{{classname}}} { {{^oneOf}} {{#mappedModels}} #[serde(rename="{{mappingName}}")] diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 7744be306e..f1810c88f9 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1182,5 +1182,69 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-63feca70-identity-and-statistics-product-qualification" + }, + { + "id": "sqlite-atomic-d9686bba-v1", + "serverCommit": "d9686bba60560ea292269cd814619c44764da60a", + "contractSha256": "8f04516fa14119300005366b7de26a42f5fa0a43119209b7e4654987b611aaf9", + "artifactSha256": "9b0f2234c43079c7883bb6bfa4bc3156c5209cf13f230dae34418a096b21aada", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-d9686bba-protocol-qualification" + }, + { + "id": "sqlite-locators-ccfa6f4d-v1", + "serverCommit": "ccfa6f4d221f78b38c5e6a76f21535eb40100226", + "contractSha256": "18af205a886552b6c2d9f41fb72a52cbf4453ddc70a98a47103212b4c16f2a6f", + "artifactSha256": "1229552d6e8054c2ee3fbb22eb143ae56ea996fa8fe2fe5873a2474b0ddf6a6c", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_connection_identity", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-ccfa6f4d-protocol-qualification" + }, + { + "id": "sqlite-source-plugins-c00dbf14-v1", + "serverCommit": "c00dbf1455e1985f6a782b4d39c9ee8cde6e74b7", + "contractSha256": "3ea9f0661702254563e72d2291469b84a961730f572a32467f15458e7a979650", + "artifactSha256": "5555c090e6efa7b2194bd643b6f95e495aec17e9ed87e55a0cbc744ab2b7c529", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_connection_identity", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-c00dbf14-source-plugin-protocol-qualification" } ] diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 87f1b4d748..7a2f991d51 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "e54d69cdcbeef19a816e92670901aacda9c7e44df289c88c18ad5ae364a6efeb", + "contractSha256": "3ea9f0661702254563e72d2291469b84a961730f572a32467f15458e7a979650", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/src-tauri/src/transport/wire.rs b/desktop/src-tauri/src/transport/wire.rs index 06da3e99e6..d199661f9f 100644 --- a/desktop/src-tauri/src/transport/wire.rs +++ b/desktop/src-tauri/src/transport/wire.rs @@ -2523,7 +2523,7 @@ impl std::fmt::Display for ScopeQueryField { } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "mode")] +#[serde(untagged)] pub enum ScopeSelection { #[serde(rename = "AllScopeSelection")] AllScopeSelection(AllScopeSelection), @@ -2751,6 +2751,94 @@ impl SourceInventoryStatistics { } } +/// SourceLineRangeLocator : Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect. +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct SourceLineRangeLocator { + #[serde(rename = "kind")] + pub kind: SourceLineRangeLocatorKind, + #[serde(rename = "start_line")] + pub start_line: i64, + #[serde(rename = "end_line")] + pub end_line: i64, + /// Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes. + #[ts(optional)] + #[ts(type = "string | null")] + #[serde( + rename = "heading_path", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub heading_path: Option>, +} + +impl SourceLineRangeLocator { + /// Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect. + #[allow(clippy::too_many_arguments)] + pub fn new( + kind: SourceLineRangeLocatorKind, + start_line: i64, + end_line: i64, + ) -> SourceLineRangeLocator { + SourceLineRangeLocator { + kind, + start_line, + end_line, + heading_path: None, + } + } +} +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum SourceLineRangeLocatorKind { + #[serde(rename = "line_range")] + #[default] + LineRange, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(untagged)] +pub enum SourceLocator { + #[serde(rename = "sequence")] + Sequence(SourceSequenceLocator), + #[serde(rename = "line_range")] + LineRange(SourceLineRangeLocator), +} + +impl Default for SourceLocator { + fn default() -> Self { + Self::Sequence(Default::default()) + } +} + +/// SourceSequenceLocator : Position of this exact Source in its scope journal; zero is distinct from absence. +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct SourceSequenceLocator { + #[serde(rename = "kind")] + pub kind: SourceSequenceLocatorKind, + #[serde(rename = "sequence")] + pub sequence: i64, +} + +impl SourceSequenceLocator { + /// Position of this exact Source in its scope journal; zero is distinct from absence. + #[allow(clippy::too_many_arguments)] + pub fn new(kind: SourceSequenceLocatorKind, sequence: i64) -> SourceSequenceLocator { + SourceSequenceLocator { kind, sequence } + } +} +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum SourceSequenceLocatorKind { + #[serde(rename = "sequence")] + #[default] + Sequence, +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct SourceTypeReference { @@ -2760,6 +2848,9 @@ pub struct SourceTypeReference { /// Source identity as accepted at ingestion, including Unicode and interior spaces. #[serde(rename = "source_id")] pub source_id: String, + #[ts(optional)] + #[serde(rename = "locator", skip_serializing_if = "Option::is_none")] + pub locator: Option, } impl SourceTypeReference { @@ -2768,6 +2859,7 @@ impl SourceTypeReference { SourceTypeReference { source_type, source_id, + locator: None, } } } @@ -3005,6 +3097,11 @@ pub fn declarations(config: &ts_rs::Config) -> Vec { ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), diff --git a/desktop/src-tauri/tests/api.rs b/desktop/src-tauri/tests/api.rs index 0997e6eec5..747bf41e44 100644 --- a/desktop/src-tauri/tests/api.rs +++ b/desktop/src-tauri/tests/api.rs @@ -447,3 +447,44 @@ async fn atomic_write_rejects_inconsistent_merge_state_without_losing_dispatch_u request.await.unwrap(); } } + +#[test] +fn source_locators_preserve_variant_fields_and_a_single_tag_on_roundtrip() { + use powercontext_desktop::transport::wire::SourceLocator; + for (wire, sequence) in [ + (r#"{"kind":"sequence","sequence":0}"#, true), + ( + r#"{"kind":"line_range","start_line":1,"end_line":3,"heading_path":"中文 / Heading"}"#, + false, + ), + ] { + let locator: SourceLocator = serde_json::from_str(wire).unwrap(); + assert_eq!(matches!(&locator, SourceLocator::Sequence(_)), sequence); + let encoded = serde_json::to_string(&locator).unwrap(); + assert_eq!(encoded.matches("\"kind\"").count(), 1); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + serde_json::from_str::(wire).unwrap() + ); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + locator + ); + } +} + +#[test] +fn source_locators_reject_unknown_or_mismatched_tags() { + use powercontext_desktop::transport::wire::SourceLocator; + for wire in [ + r#"{"kind":"future","sequence":0}"#, + r#"{"kind":"sequence","start_line":1,"end_line":3}"#, + r#"{"kind":"line_range","sequence":0}"#, + r#"{"sequence":0}"#, + ] { + assert!( + serde_json::from_str::(wire).is_err(), + "{wire}" + ); + } +} diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 057f544079..888efc0493 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -3299,6 +3299,7 @@ export interface components { DreamSourceReference: { source_type: string; source_id: string; + locator?: components["schemas"]["SourceLocator"]; }; ListDreamRunsRequest: { status?: components["schemas"]["DreamStatus"]; @@ -5759,16 +5760,40 @@ export interface components { items: components["schemas"]["SourceRecord"][]; next_cursor: string | null; }; + /** @description Position of this exact Source in its scope journal; zero is distinct from absence. */ + SourceSequenceLocator: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "sequence"; + sequence: number; + }; + /** @description Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect. */ + SourceLineRangeLocator: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "line_range"; + start_line: number; + end_line: number; + /** @description Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes. */ + heading_path?: string | null; + }; + SourceLocator: components["schemas"]["SourceSequenceLocator"] | components["schemas"]["SourceLineRangeLocator"]; SourceTypeReference: { /** @description Stable Source type, including dynamically registered Source names. */ source_type: string; /** @description Source identity as accepted at ingestion, including Unicode and interior spaces. */ source_id: string; + locator?: components["schemas"]["SourceLocator"]; }; SourceReference: { /** @description Stable Source type. */ name: string; source_id: string; + locator?: components["schemas"]["SourceLocator"]; }; /** @enum {string} */ CaptureStatus: "accepted"; diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index b2446beefb..d758833197 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -362,7 +362,7 @@ export type ScopeDescriptor = { scope_id: string, title: string, summary: string export type ScopeExternalReference = { kind: string, value: string, }; export type ScopePage = { items: Array, next_cursor?: string | null, }; export type ScopeQueryField = "scope_id" | "title" | "summary" | "external_reference_value" | "binding_external_id"; -export type ScopeSelection = { "mode": "AllScopeSelection" } & AllScopeSelection | { "mode": "ExactScopeSelection" } & ExactScopeSelection | { "mode": "SubtreeScopeSelection" } & SubtreeScopeSelection; +export type ScopeSelection = AllScopeSelection | ExactScopeSelection | SubtreeScopeSelection; export type ScopeStats = { scope_id: string, inventory: InventoryStatistics, usage: UsageStatistics, recall: RecallTokenStatistics, recurrence: RecurrenceStatistics, /** * Current durable bindings and locally observed scheduling state for this Scope; not a complete attempt history. @@ -390,6 +390,15 @@ api_contract_version: ContractVersion, feature_contracts: { [key in string]: FeatureContract }, }; export type ShareUnit = "artifact" | "memory_entry"; export type SourceInventoryStatistics = { total: number, memory_processed: number, memory_pending: number, }; +export type SourceLineRangeLocator = { kind: SourceLineRangeLocatorKind, start_line: number, end_line: number, +/** + * Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes. + */ +heading_path?: string | null, }; +export type SourceLineRangeLocatorKind = "line_range"; +export type SourceLocator = SourceSequenceLocator | SourceLineRangeLocator; +export type SourceSequenceLocator = { kind: SourceSequenceLocatorKind, sequence: number, }; +export type SourceSequenceLocatorKind = "sequence"; export type SourceTypeReference = { /** * Stable Source type, including dynamically registered Source names. @@ -398,7 +407,7 @@ source_type: string, /** * Source identity as accepted at ingestion, including Unicode and interior spaces. */ -source_id: string, }; +source_id: string, locator?: SourceLocator, }; export type Stage = "inference" | "flush" | "worker" | "supervisor" | "lease_renewal" | "scope_discovery"; export type State = "running" | "degraded" | "stopped" | "unknown"; export type StatsPeriod = "today" | "7d" | "30d"; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index f2dc9875b0..c34bccb535 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 = "e54d69cdcbeef19a816e92670901aacda9c7e44df289c88c18ad5ae364a6efeb"; +export const contractSha256 = "3ea9f0661702254563e72d2291469b84a961730f572a32467f15458e7a979650"; export const operations = { "get_liveness": { "method": "GET", diff --git a/desktop/ui/tests/connections.test.tsx b/desktop/ui/tests/connections.test.tsx index 423500c912..526203157a 100644 --- a/desktop/ui/tests/connections.test.tsx +++ b/desktop/ui/tests/connections.test.tsx @@ -415,9 +415,12 @@ test("new connections offer only current-contract profiles and keep no selection }); test("a saved authenticated legacy profile remains selectable only in its qualified authentication mode", async () => { const user = userEvent.setup(); - const legacy = qualifications.find( - (p) => p.id === "sqlite-atomic-112db11e-v1", - )!; + const legacy: CompatibilityProfile = { + ...qualifications.find((p) => p.id === "sqlite-atomic-112db11e-v1")!, + authenticatedClients: { + [contractSha256]: "Synthetic authenticated client qualification", + }, + }; const saved = { ...profile, compatibility: legacy.id }; vi.mocked(desktopApi.invalidate).mockResolvedValue(state(saved)); render( diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index e3ef2f3df8..bfd0284fa6 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -17,8 +17,8 @@ The design follows [RFC #1771](https://github.com/oceanbase/powercontext/pull/17 The full Server framework uses two normalization checkpoints before its shared migration chain for the PowerContext 1.5.0 target (stored compatibility IDs shown below): -- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002 → v1_5_0_003`. -- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002 → v1_5_0_003` through a separate frozen source bridge. +- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002 → v1_5_0_003 → v1_5_0_004`. +- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002 → v1_5_0_003 → v1_5_0_004` 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. @@ -90,6 +90,9 @@ Revision `v1_4_1_004` registers `pc_access_owner_recovery` for durable Artifact 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. + +Revision `v1_5_0_004` registers `pc_artifact_source_locators`, an optional relation keyed by the existing Artifact Revision and Source-edge ordinal. Existing managed databases require explicit migration before Server startup. The migration preserves existing lineage rows, exact identities and historical payloads without rewriting them or backfilling locators. Atomic Memory embeddings remain unchanged; no re-embedding is required. See [Source citation locators](../workflows/sources.md) for locator behavior. + Revision `v1_4_1_006` registers `pc_source_write_plugins` for persisted Source write plugin declarations. Revision `v1_5_0_001` adds Artifact plugin registration fields, family schemas, search state and projections, index coverage and change tracking, and vector-space/index registrations. SQLite also registers portable vector @@ -159,12 +162,13 @@ Sequence `000` identifies a registered historical baseline; upgrade steps start | `v1_5_0_001` | 1.5.0 | 1 | `v1_5_0_001` | Artifact plugin storage and search registrations | | `v1_5_0_002` | 1.5.0 | 2 | `v1_5_0_002` | Retained Source evidence and historical validation | | `v1_5_0_003` | 1.5.0 | 3 | `v1_5_0_003` | Disposable exact-revision evidence projection | +| `v1_5_0_004` | 1.5.0 | 4 | `v1_5_0_004` | Source citation position locators | -Both registered source paths reach `v1_5_0_003`; only 1.3.0 is selected for CI upgrade acceptance: +Both registered source paths reach `v1_5_0_004`; 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 -> v1_4_1_004 -> v1_4_1_005 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 -> v1_5_0_003 -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 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 -> v1_5_0_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 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 -> v1_5_0_003 -> v1_5_0_004 +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 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 -> v1_5_0_003 -> v1_5_0_004 ``` `status` and `plan` show `source_migration`, `target_migration` and `pending_migrations` @@ -186,7 +190,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 (`v1_5_0_003` when appending directly to the current head), even if that ID has +the actual preceding ID (`v1_5_0_004` 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/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 3fa36ab702..bc03b72e1a 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -260,8 +260,8 @@ so the previous database remains available for recovery: exported `pc_scopes` data. If the source predates the three Skill lifecycle tables (`pc_skill_packages`, `pc_agent_skill_targets`, and `pc_skill_publications`), the Profile tables, `pc_topic_memory_work_budgets`, `pc_topic_memory_work_blocks`, - `pc_recall_effort_daily`, `pc_receipt_migration_review`, `pc_context_bootstrap_receipts`, or - `pc_decision_observations`, remove the absent tables from their respective layers. + `pc_recall_effort_daily`, `pc_receipt_migration_review`, `pc_context_bootstrap_receipts`, + `pc_decision_observations`, or `pc_artifact_source_locators`, remove the absent tables from their respective layers. When a work-budget table exists, restore it together with Cursors so failure allowances survive the migration. Restore work-attempt records as well to preserve block identities, diagnostics and accepted retry receipts. @@ -285,7 +285,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_candidate_heads,pc_topic_memory_active_topics,pc_topic_memory_active_chunks,pc_memory_entry_heads,pc_artifact_tags,pc_recurrence_match,pc_recurrence_observation' \ + --table 'pc_artifact_source_locators,pc_candidate_heads,pc_topic_memory_active_topics,pc_topic_memory_active_chunks,pc_memory_entry_heads,pc_artifact_tags,pc_recurrence_match,pc_recurrence_observation' \ -f ``` diff --git a/docs/en/docs/workflows/sources.md b/docs/en/docs/workflows/sources.md index 51090d4a5a..fc70d9a230 100644 --- a/docs/en/docs/workflows/sources.md +++ b/docs/en/docs/workflows/sources.md @@ -84,3 +84,59 @@ A declaration that can no longer be attested resolves to `untrusted`/`unknown`. exact Source material raises `AtomicMemoryEvidenceError`, which callers catch through the `PowerContextError` family. Source writes and reads are subject to the selected [Scope and access policy](scopes-and-access.md). + +## Cite a position within a Source + +A Source reference can carry an optional `locator`. The Source identity remains `(source_type, source_id)` within +its Scope; a locator describes evidence position and never selects another Source or Artifact Revision. +HTTP inputs that use `name` instead of `source_type` accept the same locator. + +```json +{ + "source_type": "document", + "source_id": "design-note", + "locator": { + "kind": "line_range", + "start_line": 40, + "end_line": 55, + "heading_path": "Architecture / Constraints" + } +} +``` + +- `kind: sequence` carries a strict nonnegative integer `sequence`, the Source's position in the Scope journal. + The stored journal currently starts at 1. A supplied position must match that Source's recorded position; + a mismatched value is rejected, not used to redirect the reference. A serialized zero is distinct from no locator. +- `kind: line_range` carries strict positive integers `start_line` and `end_line`. Both endpoints are included; + the range must contain between 1 and 200 lines. Invalid ranges are rejected with `invalid_request` on HTTP, + with `source_locator_range` in validation details for reversed or excessive spans; they are never truncated. +- `heading_path` is optional on a line range and contains at most 1024 Unicode characters, not UTF-8 bytes. + Lines refer to the exact decoded text supplied by the adapter. These locators introduce no byte offsets. + +Atomic Memory extraction derives a journal locator from the persisted capture position for built-in captured +Content Sources and other Sources without a finer position. If an adapter reports a sequence locator in +`LocatedTextEvidence.locator`, extraction uses the persisted Source journal position for the citation, including for +previously accepted observations; the adapter's evidence is retained unchanged. An adapter may also report a line +range in `LocatedTextEvidence.locator`; worker observations preserve it in the text-evidence projection, and extraction +keeps it instead of guessing an offset. Adapters must report a location in their exact evidence text, not in a subsequently +edited file. Opaque sources and ordinary caller-supplied references may omit the locator. Other generation APIs +preserve explicitly supplied locators; they do not infer line numbers or heading paths. No read-time text scan or +historical backfill is performed. Existing worker manifests retain their exact fingerprints; a worker adopting +the expanded text-evidence schema must increment its Source Definition version before registering it. +`TextEvidence` keeps its original schema for existing Source plugins; `TextEvidenceV1` is an alias for that +stable model. Plugins that need positions explicitly use `LocatedTextEvidence` and increment their Source Definition +version before registering the expanded schema. Built-in Content and OpenDAL version-1 definitions retain the +original shape; their journal locators are attached during extraction. + +Locators survive candidate payloads, Artifact lineage, Handoff citations, HTTP/MCP reads and portable bundle +export/restore. Distinct positions in the same Source remain distinct citations. A missing locator stays omitted +in serialized references, preserving existing records and canonical encodings. Exact Artifact Revision and Source +eligibility checks still apply. + +Server database revision `v1_5_0_004` adds the optional `pc_artifact_source_locators` relation keyed by the existing +Artifact Revision and Source-edge ordinal. Upgrade existing managed databases through the normal +[database migration workflow](../operate/database-migrations.md) before Server startup; existing lineage rows and +historical payloads are not rewritten. Atomic Memory embeddings are bound to its content, not this lineage metadata, +so this upgrade does not invalidate existing vectors or require re-embedding. A Handoff that includes a locator in +its content has a corresponding content digest, as for any other content change. Older binaries reject portable +bundles containing the new `artifact_source_locator` record kind; bundles without it remain supported. diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index be639972e5..4bb5832a79 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 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002 → v1_5_0_003`。 -- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002 → v1_5_0_003`。 +- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002 → v1_5_0_003 → v1_5_0_004`。 +- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002 → v1_5_0_003 → v1_5_0_004`。 应用发布版本与 schema revision 分别编号。1.4.0 发行包不等于 `s1400` 检查点:其 Candidate 结构已经是当前格式, 但 Topic 尝试状态仍需登记的桥接。两条路径保留既有引用和历史,到达相同的受管目标结构;原有资源摘要分别保留, @@ -73,6 +73,9 @@ payload 身份及待处理 intent。兼容的当前任务原样保留,不套 `v1_4_1_005` 为文本和向量配置添加可空的 `pc_atomic_memory_current.description` 列。已有受管库需要显式迁移,启动不会修改该列。新增列不改写历史 Artifact 内容;已有投影的摘要保持空值,直到新写入或显式投影重建复制权威摘要。 `v1_4_1_006` 登记 `pc_source_write_plugins`,用于持久保存 Source write 插件声明。 + +修订 `v1_5_0_004` 登记可选关系表 `pc_artifact_source_locators`,以既有 Artifact Revision 和 Source 边序号为键。已有受管数据库必须在 Server 启动前显式迁移。迁移保留既有血缘行、精确身份及历史负载,不重写这些记录,也不回填定位信息。Atomic Memory 的历史向量保持不变,无需重新生成 embedding。定位信息的行为参见 [Source 引用位置](../workflows/sources.md)。 + `v1_5_0_001` 增加 Artifact 插件注册字段、family schema、检索状态和投影、索引覆盖与变更跟踪,以及向量空间和索引登记。 SQLite 还登记可移植向量和 FTS 辅助结构。两条来源链均通过显式维护加入这些增量变更;历史修订资源与校验和保持不变。 根据已登记空间创建的原生向量表,必须通过精确布局检查才能纳入后续维护。上述登记不扩展受支持的来源版本, @@ -131,12 +134,13 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 | `v1_5_0_001` | 1.5.0 | 第一步 | `v1_5_0_001` | Artifact 插件存储与检索登记 | | `v1_5_0_002` | 1.5.0 | 第二步 | `v1_5_0_002` | 保留 Source 证据及历史验证 | | `v1_5_0_003` | 1.5.0 | 第三步 | `v1_5_0_003` | 可丢弃的精确 revision 证据投影 | +| `v1_5_0_004` | 1.5.0 | 第四步 | `v1_5_0_004` | Source 引用位置定位信息 | -两条已登记来源路径都升级到 `v1_5_0_003`;CI 升级验收只选择 1.3.0: +两条已登记来源路径都升级到 `v1_5_0_004`;CI 升级验收只选择 1.3.0: ```text -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 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 -> v1_5_0_003 -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 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 -> v1_5_0_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 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 -> v1_5_0_003 -> v1_5_0_004 +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 -> v1_4_1_006 -> v1_5_0_001 -> v1_5_0_002 -> v1_5_0_003 -> v1_5_0_004 ``` `status` 和 `plan` 在原有机器字段之前显示 `source_migration`、`target_migration`、 @@ -155,7 +159,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 这样可以继续使用已有计划和原始备份。局部原型 bundle 保留自己的独立编号。 后续新增 Server 迁移直接使用该命名作为 Alembic `revision`,并正常登记结构、领域步骤和恢复检查点。 -`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_5_0_003`,即使它已有展示别名。 +`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_5_0_004`,即使它已有展示别名。 框架按照登记的链执行,不按名称字典序推算。已登记的名称和所属发布版本保持不变,版本升级不能重命名历史。 ## 初始化与维护职责 diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index 18d9617449..399e2e70e4 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -252,7 +252,7 @@ collation,但不会包含数据库 URL 或凭据。 源数据库早于三张 Skill 生命周期表(`pc_skill_packages`、`pc_agent_skill_targets` 和 `pc_skill_publications`)、Profile 表、`pc_topic_memory_work_budgets`、`pc_topic_memory_work_blocks`、 `pc_recall_effort_daily`、`pc_receipt_migration_review`、`pc_context_bootstrap_receipts` 或 - `pc_decision_observations` 时,应从对应层删除缺失的表。 + `pc_decision_observations` 或 `pc_artifact_source_locators` 时,应从对应层删除缺失的表。 如果存在 work-budget 表,必须与 Cursor 一起恢复,保留已消耗的失败额度,避免迁移后重置累计成本。 work-attempt 表也应一起恢复,保留阻塞身份、诊断信息与已接受的重试回执。 @@ -276,7 +276,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_candidate_heads,pc_topic_memory_active_topics,pc_topic_memory_active_chunks,pc_memory_entry_heads,pc_artifact_tags,pc_recurrence_match,pc_recurrence_observation' \ + --table 'pc_artifact_source_locators,pc_candidate_heads,pc_topic_memory_active_topics,pc_topic_memory_active_chunks,pc_memory_entry_heads,pc_artifact_tags,pc_recurrence_match,pc_recurrence_observation' \ -f ``` diff --git a/docs/zh/docs/workflows/sources.md b/docs/zh/docs/workflows/sources.md index f4aa54661b..58d6fac0b1 100644 --- a/docs/zh/docs/workflows/sources.md +++ b/docs/zh/docs/workflows/sources.md @@ -79,3 +79,52 @@ for source in annotation.sources: `AtomicMemoryEvidenceError`,调用方可通过 `PowerContextError` 家族捕获。 Source 读写受所选 [Scope 与访问策略](scopes-and-access.md)约束。 + +## 引用 Source 中的具体位置 + +Source 引用可携带可选的 `locator`。Source 在 Scope 内的身份仍由 `(source_type, source_id)` 确定; +locator 只描述证据位置,不会选择其他 Source 或 Artifact Revision。 +使用 `name` 代替 `source_type` 的 HTTP 输入同样接受该字段。 + +```json +{ + "source_type": "document", + "source_id": "design-note", + "locator": { + "kind": "line_range", + "start_line": 40, + "end_line": 55, + "heading_path": "架构 / 约束" + } +} +``` + +- `kind: sequence` 使用严格的非负整数 `sequence`,表示 Source 在当前 Scope journal 中的位置。 + 当前 journal 从 1 开始。提交的位置必须与该 Source 的记录一致,否则明确拒绝,不会跳转到其他记录。 + 序列化后的零值与没有 locator 可以区分。 +- `kind: line_range` 使用严格的正整数 `start_line` 和 `end_line`,包含首尾两行,范围必须为 1 至 200 行。 + 非法范围在 HTTP 返回 `invalid_request`;范围倒置或跨度超限时,校验详情包含 `source_locator_range`,不会静默截断。 +- 行范围可附带 `heading_path`,最多 1024 个 Unicode 字符,按字符而非 UTF-8 字节计数。 + 行号对应适配器提供的准确解码文本;此功能不引入字节偏移量。 + +Atomic Memory 提取会为内置 Content Source 及没有更细位置的 Source,根据持久化的采集位置生成 journal locator。 +若适配器在 `LocatedTextEvidence.locator` 中报告 sequence locator,提取时会使用 Source 实际持久化的 journal 位置生成引用, +已有 observation 也遵循此规则;适配器提供的证据保持原样。 +适配器可通过 `LocatedTextEvidence.locator` 报告行范围;远程 worker observation 在文本证据投影中保存该信息,提取时原样保留, +不会猜测偏移。适配器报告的位置必须对应准确的证据文本,不能指向随后被编辑的文件。 +不透明来源和调用方提供的普通引用可以省略 locator。其他生成接口保留显式传入的 locator,不推测行号或标题路径。 +读取时不会扫描文本定位,也不会给历史引用回填推测的位置。已有 worker manifest 保留原指纹; +worker 采用扩展后的文本证据 schema 时,须先提高 Source Definition 版本再注册。 +`TextEvidence` 为已有 Source 插件保留原始 schema;`TextEvidenceV1` 是该稳定模型的别名。 +需要位置的插件须显式采用 `LocatedTextEvidence`,并先提高 Source Definition 版本再注册扩展后的 schema。 +内置 Content 与 OpenDAL 的 version-1 定义保持原始结构,其 journal locator 在提取时附加。 + +locator 会经过 Candidate 负载、Artifact 血缘、Handoff 引用、HTTP/MCP 读取以及便携备份导出恢复而保留。 +同一 Source 的不同位置是不同的引用。缺失的 locator 在序列化时仍被省略,已有记录及其规范编码保持兼容。 +精确 Artifact Revision 校验及 Source 使用资格校验继续生效。 + +Server 数据库修订 `v1_5_0_004` 增加可选的 `pc_artifact_source_locators` 关系表,按现有 Artifact Revision +及来源引用序号关联。已有托管数据库须在 Server 启动前按[数据库迁移流程](../operate/database-migrations.md)升级; +迁移不会重写已有血缘行或历史负载。Atomic Memory 向量绑定记忆内容,不包含这份血缘位置元数据,因此升级不会导致既有向量失效, +也无需重新嵌入。Handoff 内容中新增 locator 时,内容摘要随之变化,与其他内容修改一致。 +旧版本程序会拒绝含新 `artifact_source_locator` 记录类型的便携备份;不含该记录的旧备份继续受支持。 diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock index 28bef7cda9..bc77b557ab 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -894,8 +894,8 @@ name = "httpcore2" version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform != 'emscripten'" }, - { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } wheels = [ @@ -1719,7 +1719,7 @@ requires-dist = [ { name = "pycasbin", marker = "extra == 'ops'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'seekdb'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'server'", specifier = ">=2.8,<3" }, - { name = "pydantic", specifier = ">=2.10,<3" }, + { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'cli'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'ops'", specifier = ">=2.27.1,<3" }, diff --git a/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-locators/report.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-locators/report.json new file mode 100644 index 0000000000..2b4428492a --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-locators/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.v2", + "compiled_digest": "ff2f658a3c3c3e655c54d9daaea169be35a25e5463a327804ef6a0c8b2fbd778", + "definition_version": "powercontext.prompt.atomic-memory.extract.v1", + "instructions_sha256": "87c82c353bc7a4b52a34ddcbdc1ef2cd288128b840dd5e3c6ca414efcfcf3dde", + "key": "atomic_memory.extract" + }, + "conversation": { + "builtin_version": "powercontext.atomic-memory.extract.conversation.v2", + "compiled_digest": "95a803a06bf2131fdb4bdb646930996850b2c57c8ab23060ba32787d4fbc71a2", + "definition_version": "powercontext.prompt.atomic-memory.extract.v1", + "instructions_sha256": "2a603e600272542cddc7b5079853600abe977063bffe82b49822e40083e6bb38", + "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": 30.0 + }, + "run_id": "atomic-extraction-20261030-locators-02", + "run_manifest_sha256": "0d4a3302cbb1cef326055e632cc09f8f904770f7118799c2edf499a65ee9706b", + "schema_version": "powercontext.atomic-memory-extraction-report.v1", + "status": "passed" +} diff --git a/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-locators/report.md b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-locators/report.md new file mode 100644 index 0000000000..2700101161 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-locators/report.md @@ -0,0 +1,23 @@ +# Atomic Memory extraction live-run report + +- Status: `passed` +- Run: `atomic-extraction-20261030-locators-02` +- 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.v2` / `87c82c353bc7a4b52a34ddcbdc1ef2cd288128b840dd5e3c6ca414efcfcf3dde` +- `conversation`: `powercontext.atomic-memory.extract.conversation.v2` / `2a603e600272542cddc7b5079853600abe977063bffe82b49822e40083e6bb38` + +## 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/archive/atomic-extraction-20261030-locators/run-manifest.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-locators/run-manifest.json new file mode 100644 index 0000000000..fe373e5466 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-locators/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.v2", + "compiled_digest": "ff2f658a3c3c3e655c54d9daaea169be35a25e5463a327804ef6a0c8b2fbd778", + "definition_version": "powercontext.prompt.atomic-memory.extract.v1", + "instructions_sha256": "87c82c353bc7a4b52a34ddcbdc1ef2cd288128b840dd5e3c6ca414efcfcf3dde", + "key": "atomic_memory.extract" + }, + "conversation": { + "builtin_version": "powercontext.atomic-memory.extract.conversation.v2", + "compiled_digest": "95a803a06bf2131fdb4bdb646930996850b2c57c8ab23060ba32787d4fbc71a2", + "definition_version": "powercontext.prompt.atomic-memory.extract.v1", + "instructions_sha256": "2a603e600272542cddc7b5079853600abe977063bffe82b49822e40083e6bb38", + "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": 30.0 + }, + "run_id": "atomic-extraction-20261030-locators-02", + "schema_version": "powercontext.atomic-memory-extraction-run.v1", + "started_at": "2026-10-30T06:55:31.150799Z" +} diff --git a/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.json new file mode 100644 index 0000000000..0a2a042189 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/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": "53a0e4e506df5a7eabd03aa4829ccfc80bc8cee9d9e9e76a9dc50dd8f9593ba0", + "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": "07dd039ca5c97790196e2e3c7d43a0124c257acb79559446df5272b9ea002243", + "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-20261030-merged-locators-120s", + "run_manifest_sha256": "20a1be4d0f9f138e2b2c7533b403a0003683e405076ea26f46f0e30e2349e731", + "schema_version": "powercontext.atomic-memory-extraction-report.v1", + "status": "passed" +} diff --git a/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.md b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.md new file mode 100644 index 0000000000..fa1558ba5e --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.md @@ -0,0 +1,23 @@ +# Atomic Memory extraction live-run report + +- Status: `passed` +- Run: `atomic-extraction-20261030-merged-locators-120s` +- 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/archive/atomic-extraction-20261030-merged-locators/run-manifest.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/run-manifest.json new file mode 100644 index 0000000000..3c16042a9e --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/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": "53a0e4e506df5a7eabd03aa4829ccfc80bc8cee9d9e9e76a9dc50dd8f9593ba0", + "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": "07dd039ca5c97790196e2e3c7d43a0124c257acb79559446df5272b9ea002243", + "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-20261030-merged-locators-120s", + "schema_version": "powercontext.atomic-memory-extraction-run.v1", + "started_at": "2026-10-30T09:06:52.638318Z" +} diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/report.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/report.json similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/report.json rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/report.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/report.md b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/report.md similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/report.md rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/report.md diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.json b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.json new file mode 100644 index 0000000000..b543343c70 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.json @@ -0,0 +1,80 @@ +{ + "content_policy": "aggregate_only_no_evidence_or_generated_candidate_text", + "dataset": { + "case_count": 9, + "dataset_version": "powercontext.atomic-memory-extraction.synthetic.v2", + "manifest_sha256": "853d22abc12ede5ccdd46e380cb935b9746c8003fc484d0934366bb8f5df22c6", + "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.v4", + "compiled_digest": "5545efec2f457d908a2717dca85157db3faab320a378837bd1eea3ecbbaf0d51", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "da5de7be4ac0e23f5e718076dc51b5f1825f376bfe2f071278ff5be2dc096a97", + "key": "atomic_memory.extract" + }, + "conversation": { + "builtin_version": "powercontext.atomic-memory.extract.conversation.v4", + "compiled_digest": "cbfaf95eb4ffa83531b9f0b84864fda33b9a83f102acdbad2e7466c44aa2759d", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "503f212db750b5d0db4b5e84d6c88ad42f90e8c51bbd0af3d1a9a50e7bb6afc4", + "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": { + "temperature": 0 + }, + "model_settings_sha256": "e4ff491169b8a9ea78518a7972422b5a24d5e3790e0d9a9e5cbfb384d9b621e0", + "provider": "openai-chat", + "timeout_seconds": 120.0 + }, + "run_id": "atomic-extraction-20261107-locators-v2-deterministic", + "run_manifest_sha256": "377d036c3a3fe1d29158cc3eef2b8d2ada2674a363e9e8c87cec73ce1edccc5a", + "schema_version": "powercontext.atomic-memory-extraction-report.v1", + "status": "passed" +} diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.md b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.md new file mode 100644 index 0000000000..4ea81f0af1 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.md @@ -0,0 +1,23 @@ +# Atomic Memory extraction live-run report + +- Status: `passed` +- Run: `atomic-extraction-20261107-locators-v2-deterministic` +- Dataset: `powercontext.atomic-memory-extraction.synthetic.v2` (`853d22abc12ede5ccdd46e380cb935b9746c8003fc484d0934366bb8f5df22c6`) +- 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.v4` / `da5de7be4ac0e23f5e718076dc51b5f1825f376bfe2f071278ff5be2dc096a97` +- `conversation`: `powercontext.atomic-memory.extract.conversation.v4` / `503f212db750b5d0db4b5e84d6c88ad42f90e8c51bbd0af3d1a9a50e7bb6afc4` + +## 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-20261107-locators-v2-deterministic/run-manifest.json b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/run-manifest.json new file mode 100644 index 0000000000..5beea51b08 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/run-manifest.json @@ -0,0 +1,42 @@ +{ + "dataset": { + "case_count": 9, + "dataset_version": "powercontext.atomic-memory-extraction.synthetic.v2", + "manifest_sha256": "853d22abc12ede5ccdd46e380cb935b9746c8003fc484d0934366bb8f5df22c6", + "schema_version": "powercontext.atomic-memory-extraction-cases.v1" + }, + "prompts": { + "coding": { + "builtin_version": "powercontext.atomic-memory.extract.coding.v4", + "compiled_digest": "5545efec2f457d908a2717dca85157db3faab320a378837bd1eea3ecbbaf0d51", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "da5de7be4ac0e23f5e718076dc51b5f1825f376bfe2f071278ff5be2dc096a97", + "key": "atomic_memory.extract" + }, + "conversation": { + "builtin_version": "powercontext.atomic-memory.extract.conversation.v4", + "compiled_digest": "cbfaf95eb4ffa83531b9f0b84864fda33b9a83f102acdbad2e7466c44aa2759d", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "503f212db750b5d0db4b5e84d6c88ad42f90e8c51bbd0af3d1a9a50e7bb6afc4", + "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": { + "temperature": 0 + }, + "model_settings_sha256": "e4ff491169b8a9ea78518a7972422b5a24d5e3790e0d9a9e5cbfb384d9b621e0", + "provider": "openai-chat", + "timeout_seconds": 120.0 + }, + "run_id": "atomic-extraction-20261107-locators-v2-deterministic", + "schema_version": "powercontext.atomic-memory-extraction-run.v1", + "started_at": "2026-11-07T14:07:17.591786Z" +} diff --git a/evaluation/memory/atomic_extraction/tests/test_manifest.py b/evaluation/memory/atomic_extraction/tests/test_manifest.py index 050d5b7cb7..e509c575f0 100644 --- a/evaluation/memory/atomic_extraction/tests/test_manifest.py +++ b/evaluation/memory/atomic_extraction/tests/test_manifest.py @@ -77,6 +77,8 @@ def test_pinned_reports_match_the_current_dataset_and_prompt_contract() -> None: pinned = sorted(path for path in reports.iterdir() if (path / "run-manifest.json").is_file()) assert pinned + current_prompts = prompt_run_identities() + qualified_current_contract = False for directory in pinned: manifest = LiveRunManifest.model_validate_json((directory / "run-manifest.json").read_text(encoding="utf-8")) report = json.loads((directory / "report.json").read_text(encoding="utf-8")) @@ -84,8 +86,16 @@ def test_pinned_reports_match_the_current_dataset_and_prompt_contract() -> None: assert manifest.dataset.manifest_sha256 == loaded.sha256 assert manifest.dataset.dataset_version == loaded.manifest.dataset_version assert manifest.dataset.case_count == len(loaded.manifest.cases) - assert manifest.prompts == prompt_run_identities() assert report["dataset"] == manifest.dataset.model_dump(mode="json") + assert report["prompts"] == { + profile.value: identity.model_dump(mode="json") for profile, identity in manifest.prompts.items() + } + assert report["status"] == "passed" + qualified_current_contract |= manifest.prompts == current_prompts + + # Historical evidence keeps its original identities; it cannot qualify a + # changed contract in place of a complete, passing current live run. + assert qualified_current_contract def test_archived_reports_remain_bound_to_their_recorded_run() -> None: diff --git a/evaluation/uv.lock b/evaluation/uv.lock index c0d294ab82..9067bea047 100644 --- a/evaluation/uv.lock +++ b/evaluation/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -1774,7 +1774,7 @@ requires-dist = [ { name = "pycasbin", marker = "extra == 'ops'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'seekdb'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'server'", specifier = ">=2.8,<3" }, - { name = "pydantic", specifier = ">=2.10,<3" }, + { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'cli'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'ops'", specifier = ">=2.27.1,<3" }, diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 8ed5d35c6a..ff5ed41be6 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -5040,6 +5040,62 @@ "maxLength": 2000, "pattern": "^\\S(?:.*\\S)?$" }, + "SourceLineRangeLocator": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "start_line", + "end_line" + ], + "description": "Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect.", + "x-powercontext-source-line-range": true, + "properties": { + "kind": { + "type": "string", + "enum": [ + "line_range" + ] + }, + "start_line": { + "type": "integer", + "minimum": 1 + }, + "end_line": { + "type": "integer", + "minimum": 1 + }, + "heading_path": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024, + "description": "Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes." + }, + { + "type": "null" + } + ] + } + } + }, + "SourceLocator": { + "oneOf": [ + { + "$ref": "#/components/schemas/SourceSequenceLocator" + }, + { + "$ref": "#/components/schemas/SourceLineRangeLocator" + } + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "sequence": "#/components/schemas/SourceSequenceLocator", + "line_range": "#/components/schemas/SourceLineRangeLocator" + } + } + }, "SourceReference": { "type": "object", "additionalProperties": false, @@ -5054,6 +5110,37 @@ }, "source_id": { "type": "string" + }, + "locator": { + "anyOf": [ + { + "$ref": "#/components/schemas/SourceLocator" + }, + { + "type": "null" + } + ] + } + } + }, + "SourceSequenceLocator": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "sequence" + ], + "description": "Position of this exact Source in its scope journal; zero is distinct from absence.", + "properties": { + "kind": { + "type": "string", + "enum": [ + "sequence" + ] + }, + "sequence": { + "type": "integer", + "minimum": 0 } } }, @@ -5074,6 +5161,16 @@ "minLength": 1, "maxLength": 256, "description": "Source identity as accepted at ingestion, including Unicode and interior spaces." + }, + "locator": { + "anyOf": [ + { + "$ref": "#/components/schemas/SourceLocator" + }, + { + "type": "null" + } + ] } } }, diff --git a/integrations/dify/plugin/tools/pc_capture_source.yaml b/integrations/dify/plugin/tools/pc_capture_source.yaml index 3eaa48b9aa..b50736f703 100644 --- a/integrations/dify/plugin/tools/pc_capture_source.yaml +++ b/integrations/dify/plugin/tools/pc_capture_source.yaml @@ -112,6 +112,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/source/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/source/properties/locator' + type: object position: $ref: '#/definitions/Result/properties/position' type: number @@ -145,6 +148,57 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope journal; + zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied text, + at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded + in Unicode characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' position: type: integer minimum: 1 diff --git a/integrations/dify/plugin/tools/pc_experience_generate.yaml b/integrations/dify/plugin/tools/pc_experience_generate.yaml index cecacc3c8f..9e1f2f0d32 100644 --- a/integrations/dify/plugin/tools/pc_experience_generate.yaml +++ b/integrations/dify/plugin/tools/pc_experience_generate.yaml @@ -44,7 +44,12 @@ parameters: when unused. Decode exactly once and preserve every field. Decoded JSON Schema: {"type":"array","maxItems":32,"description":"Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references.","items":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}},"default":[]}' + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}},"default":[]}' default: '[]' - name: artifact_refs type: string @@ -218,6 +223,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/candidate/anyOf/0/properties/source_refs/items/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/candidate/anyOf/0/properties/source_refs/items/properties/locator' + type: object description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. artifact_refs: @@ -832,6 +840,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -915,6 +977,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -992,6 +1108,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -1194,6 +1364,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope + journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, + bounded in Unicode characters rather than UTF-8 + bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifact_refs: type: array maxItems: 32 diff --git a/integrations/dify/plugin/tools/pc_experience_get.yaml b/integrations/dify/plugin/tools/pc_experience_get.yaml index 04b8446957..4f5494f5aa 100644 --- a/integrations/dify/plugin/tools/pc_experience_get.yaml +++ b/integrations/dify/plugin/tools/pc_experience_get.yaml @@ -143,6 +143,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/source_refs/items/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/source_refs/items/properties/locator' + type: object artifact_refs: $ref: '#/definitions/Result/properties/artifact_refs' type: array @@ -286,6 +289,57 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope journal; + zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied text, + at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded + in Unicode characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifact_refs: type: array items: diff --git a/integrations/dify/plugin/tools/pc_handoff_activate.yaml b/integrations/dify/plugin/tools/pc_handoff_activate.yaml index 79917501ed..1f38b5cb04 100644 --- a/integrations/dify/plugin/tools/pc_handoff_activate.yaml +++ b/integrations/dify/plugin/tools/pc_handoff_activate.yaml @@ -43,7 +43,12 @@ 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: {"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}' + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}' - name: objective type: string required: true @@ -91,7 +96,12 @@ parameters: 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: {"type":"array","maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}},"default":[]}' + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}},"default":[]}' default: '[]' output_schema: type: object @@ -136,6 +146,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/boundary_source/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/boundary_source/properties/locator' + type: object previous_position: $ref: '#/definitions/Result/properties/previous_position' type: number @@ -238,6 +251,57 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope journal; + zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied text, + at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded + in Unicode characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' previous_position: type: integer minimum: 0 @@ -319,6 +383,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -399,6 +516,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -476,6 +646,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: diff --git a/integrations/dify/plugin/tools/pc_handoff_commit.yaml b/integrations/dify/plugin/tools/pc_handoff_commit.yaml index a97dbdd7e4..85db2fafbe 100644 --- a/integrations/dify/plugin/tools/pc_handoff_commit.yaml +++ b/integrations/dify/plugin/tools/pc_handoff_commit.yaml @@ -47,9 +47,24 @@ parameters: persisted generation origin. Raw copied metadata is not accepted as verified input without a valid receipt. Prompt references are configuration lineage, not factual citations.","required":["scope_id","prompt_key","selection","artifact","definition_version","builtin_version","compiled_digest","original_draft_digest","edit_status"],"properties":{"scope_id":{"type":"string","minLength":1,"maxLength":256},"prompt_key":{"type":"string","enum":["handoff.generate"]},"selection":{"type":"string","enum":["built_in","artifact"]},"artifact":{"anyOf":[{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}},{"type":"null"}]},"definition_version":{"type":"string","minLength":1,"maxLength":256},"builtin_version":{"type":"string","minLength":1,"maxLength":256},"compiled_digest":{"type":"string","pattern":"^[0-9a-f]{64}$"},"original_draft_digest":{"type":"string","pattern":"^[0-9a-f]{64}$"},"edit_status":{"type":"string","enum":["unchanged","edited"]}}},{"type":"null"}]},"schema":{"type":"string","enum":["powercontext.handoff.v1"]},"objective":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"state":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}}},"disposition":{"type":"string","enum":["continuable","blocked","complete"]},"next_action":{"anyOf":[{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}},{"type":"null"}]},"omissions":{"type":"array","maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citation"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citation":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}},{"type":"null"}]}}}}}}}}' + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}}},"disposition":{"type":"string","enum":["continuable","blocked","complete"]},"next_action":{"anyOf":[{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}},{"type":"null"}]},"omissions":{"type":"array","maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citation"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citation":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}},{"type":"null"}]}}}}}}}}' output_schema: type: object properties: @@ -216,6 +231,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/source_refs/items/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/source_refs/items/properties/locator' + type: object artifact_refs: $ref: '#/definitions/Result/properties/artifact_refs' type: array @@ -400,6 +418,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -480,6 +550,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -557,6 +679,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -608,6 +782,57 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope journal; + zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied text, + at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded + in Unicode characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifact_refs: type: array items: diff --git a/integrations/dify/plugin/tools/pc_handoff_continue.yaml b/integrations/dify/plugin/tools/pc_handoff_continue.yaml index a8918f8e16..bacc8c3bd8 100644 --- a/integrations/dify/plugin/tools/pc_handoff_continue.yaml +++ b/integrations/dify/plugin/tools/pc_handoff_continue.yaml @@ -73,9 +73,24 @@ parameters: persisted generation origin. Raw copied metadata is not accepted as verified input without a valid receipt. Prompt references are configuration lineage, not factual citations.","required":["scope_id","prompt_key","selection","artifact","definition_version","builtin_version","compiled_digest","original_draft_digest","edit_status"],"properties":{"scope_id":{"type":"string","minLength":1,"maxLength":256},"prompt_key":{"type":"string","enum":["handoff.generate"]},"selection":{"type":"string","enum":["built_in","artifact"]},"artifact":{"anyOf":[{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}},{"type":"null"}]},"definition_version":{"type":"string","minLength":1,"maxLength":256},"builtin_version":{"type":"string","minLength":1,"maxLength":256},"compiled_digest":{"type":"string","pattern":"^[0-9a-f]{64}$"},"original_draft_digest":{"type":"string","pattern":"^[0-9a-f]{64}$"},"edit_status":{"type":"string","enum":["unchanged","edited"]}}},{"type":"null"}]},"schema":{"type":"string","enum":["powercontext.handoff.v1"]},"objective":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"state":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}}},"disposition":{"type":"string","enum":["continuable","blocked","complete"]},"next_action":{"anyOf":[{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}},{"type":"null"}]},"omissions":{"type":"array","maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citation"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citation":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}},{"type":"null"}]}}}}}}}},{"type":"null"}]}' + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}}},"disposition":{"type":"string","enum":["continuable","blocked","complete"]},"next_action":{"anyOf":[{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}},{"type":"null"}]},"omissions":{"type":"array","maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citation"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citation":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}},{"type":"null"}]}}}}}}}},{"type":"null"}]}' - name: revision type: string required: false @@ -462,6 +477,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -542,6 +610,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -619,6 +740,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -758,6 +932,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its + scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: diff --git a/integrations/dify/plugin/tools/pc_handoff_finalize.yaml b/integrations/dify/plugin/tools/pc_handoff_finalize.yaml index acc6626bb9..50ccb833b9 100644 --- a/integrations/dify/plugin/tools/pc_handoff_finalize.yaml +++ b/integrations/dify/plugin/tools/pc_handoff_finalize.yaml @@ -45,9 +45,24 @@ parameters: {"type":"object","additionalProperties":false,"required":["objective","state","disposition","next_action","omissions"],"properties":{"generation":{"anyOf":[{"type":"object","additionalProperties":false,"description":"Transient Server-authenticated generation receipt. Preserve it across finalize and commit. It grants no additional authority and is never stored in the Artifact.","required":["receipt"],"properties":{"receipt":{"type":"string","minLength":1,"maxLength":8192}}},{"type":"null"}]},"objective":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"state":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}}},"disposition":{"type":"string","enum":["continuable","blocked","complete"]},"next_action":{"anyOf":[{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}},{"type":"null"}]},"omissions":{"type":"array","maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citation"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citation":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}},{"type":"null"}]}}}}}}' + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}}},"disposition":{"type":"string","enum":["continuable","blocked","complete"]},"next_action":{"anyOf":[{"type":"object","additionalProperties":false,"required":["text","citations"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citations":{"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}}},{"type":"null"}]},"omissions":{"type":"array","maxItems":64,"items":{"type":"object","additionalProperties":false,"required":["text","citation"],"properties":{"text":{"type":"string","minLength":1,"maxLength":8192,"pattern":".*\\S.*"},"citation":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}},{"type":"null"}]}}}}}}' output_schema: type: object properties: @@ -408,6 +423,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -488,6 +555,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -565,6 +684,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: diff --git a/integrations/dify/plugin/tools/pc_handoff_prepare.yaml b/integrations/dify/plugin/tools/pc_handoff_prepare.yaml index 516c7da64b..65bce7820d 100644 --- a/integrations/dify/plugin/tools/pc_handoff_prepare.yaml +++ b/integrations/dify/plugin/tools/pc_handoff_prepare.yaml @@ -73,7 +73,12 @@ parameters: 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: {"type":"array","minItems":1,"maxItems":32,"items":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","source_ref"],"properties":{"kind":{"type":"string","enum":["source"]},"source_ref":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}' + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}}}},{"type":"object","additionalProperties":false,"required":["kind","artifact_ref"],"properties":{"kind":{"type":"string","enum":["artifact"]},"artifact_ref":{"type":"object","additionalProperties":false,"required":["family","artifact_id","revision"],"properties":{"family":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"artifact_id":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[\\x21-\\x7E]+$"},"revision":{"type":"integer","minimum":1}}}}}],"discriminator":{"propertyName":"kind","mapping":{"source":"#/components/schemas/HandoffSourceCitation","artifact":"#/components/schemas/HandoffArtifactCitation"}}}}' output_schema: type: object properties: @@ -241,6 +246,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its + scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -321,6 +378,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its + scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -398,6 +507,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its + scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading + path, bounded in Unicode characters rather + than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: diff --git a/integrations/dify/plugin/tools/pc_memory_get.yaml b/integrations/dify/plugin/tools/pc_memory_get.yaml index ffb439930a..122a68d6e4 100644 --- a/integrations/dify/plugin/tools/pc_memory_get.yaml +++ b/integrations/dify/plugin/tools/pc_memory_get.yaml @@ -114,6 +114,9 @@ output_schema: type: string description: Source identity as accepted at ingestion, including Unicode and interior spaces. + locator: + $ref: '#/definitions/Result/properties/sources/items/properties/locator' + type: object artifacts: $ref: '#/definitions/Result/properties/artifacts' type: array @@ -217,6 +220,57 @@ output_schema: maxLength: 256 description: Source identity as accepted at ingestion, including Unicode and interior spaces. + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope journal; + zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied text, + at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded + in Unicode characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifacts: type: array items: diff --git a/integrations/dify/plugin/tools/pc_memory_revise.yaml b/integrations/dify/plugin/tools/pc_memory_revise.yaml index 9db902e0bc..0f16c2202e 100644 --- a/integrations/dify/plugin/tools/pc_memory_revise.yaml +++ b/integrations/dify/plugin/tools/pc_memory_revise.yaml @@ -145,6 +145,9 @@ output_schema: type: string description: Source identity as accepted at ingestion, including Unicode and interior spaces. + locator: + $ref: '#/definitions/Result/properties/sources/items/properties/locator' + type: object artifacts: $ref: '#/definitions/Result/properties/artifacts' type: array @@ -240,6 +243,57 @@ output_schema: maxLength: 256 description: Source identity as accepted at ingestion, including Unicode and interior spaces. + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope journal; + zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied text, + at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded + in Unicode characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifacts: type: array items: diff --git a/integrations/dify/plugin/tools/pc_review_get.yaml b/integrations/dify/plugin/tools/pc_review_get.yaml index 7996bb95cf..1ed7e41729 100644 --- a/integrations/dify/plugin/tools/pc_review_get.yaml +++ b/integrations/dify/plugin/tools/pc_review_get.yaml @@ -147,6 +147,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/source_refs/items/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/source_refs/items/properties/locator' + type: object description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. artifact_refs: @@ -739,6 +742,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -822,6 +878,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -899,6 +1008,59 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in + its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision + redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode characters + rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -1100,6 +1262,57 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope journal; + zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied text, + at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded + in Unicode characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifact_refs: type: array maxItems: 32 diff --git a/integrations/dify/plugin/tools/pc_review_list.yaml b/integrations/dify/plugin/tools/pc_review_list.yaml index e138a2ff58..bb1b457f19 100644 --- a/integrations/dify/plugin/tools/pc_review_list.yaml +++ b/integrations/dify/plugin/tools/pc_review_list.yaml @@ -232,6 +232,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/candidates/items/properties/source_refs/items/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/candidates/items/properties/source_refs/items/properties/locator' + type: object description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. artifact_refs: @@ -847,6 +850,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -930,6 +987,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -1007,6 +1118,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -1209,6 +1374,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope + journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, + bounded in Unicode characters rather than UTF-8 + bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifact_refs: type: array maxItems: 32 diff --git a/integrations/dify/plugin/tools/pc_skill_generate.yaml b/integrations/dify/plugin/tools/pc_skill_generate.yaml index 3c6036bc7d..cdc7d775fa 100644 --- a/integrations/dify/plugin/tools/pc_skill_generate.yaml +++ b/integrations/dify/plugin/tools/pc_skill_generate.yaml @@ -69,7 +69,12 @@ parameters: when unused. Decode exactly once and preserve every field. Decoded JSON Schema: {"type":"array","maxItems":32,"description":"Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references.","items":{"type":"object","additionalProperties":false,"required":["name","source_id"],"properties":{"name":{"type":"string","description":"Stable - Source type."},"source_id":{"type":"string"}}},"default":[]}' + Source type."},"source_id":{"type":"string"},"locator":{"anyOf":[{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind","sequence"],"description":"Position + of this exact Source in its scope journal; zero is distinct from absence.","properties":{"kind":{"type":"string","enum":["sequence"]},"sequence":{"type":"integer","minimum":0}}},{"type":"object","additionalProperties":false,"required":["kind","start_line","end_line"],"description":"Inclusive + one-based lines in adapter-supplied text, at most 200 lines. Never a revision + redirect.","x-powercontext-source-line-range":true,"properties":{"kind":{"type":"string","enum":["line_range"]},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1},"heading_path":{"anyOf":[{"type":"string","maxLength":1024,"description":"Optional + adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 + bytes."},{"type":"null"}]}}}],"discriminator":{"propertyName":"kind","mapping":{"sequence":"#/components/schemas/SourceSequenceLocator","line_range":"#/components/schemas/SourceLineRangeLocator"}}},{"type":"null"}]}}},"default":[]}' default: '[]' - name: artifact_refs type: string @@ -243,6 +248,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/candidate/anyOf/0/properties/source_refs/items/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/candidate/anyOf/0/properties/source_refs/items/properties/locator' + type: object description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. artifact_refs: @@ -857,6 +865,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -940,6 +1002,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -1017,6 +1133,60 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source + in its scope journal; zero is distinct + from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines + in adapter-supplied text, at most 200 + lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied + heading path, bounded in Unicode + characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' - type: object additionalProperties: false required: @@ -1219,6 +1389,58 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope + journal; zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied + text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, + bounded in Unicode characters rather than UTF-8 + bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifact_refs: type: array maxItems: 32 diff --git a/integrations/dify/plugin/tools/pc_skill_get.yaml b/integrations/dify/plugin/tools/pc_skill_get.yaml index 7015da279c..3e05df871f 100644 --- a/integrations/dify/plugin/tools/pc_skill_get.yaml +++ b/integrations/dify/plugin/tools/pc_skill_get.yaml @@ -145,6 +145,9 @@ output_schema: source_id: $ref: '#/definitions/Result/properties/source_refs/items/properties/source_id' type: string + locator: + $ref: '#/definitions/Result/properties/source_refs/items/properties/locator' + type: object artifact_refs: $ref: '#/definitions/Result/properties/artifact_refs' type: array @@ -294,6 +297,57 @@ output_schema: description: Stable Source type. source_id: type: string + locator: + anyOf: + - oneOf: + - type: object + additionalProperties: false + required: + - kind + - sequence + description: Position of this exact Source in its scope journal; + zero is distinct from absence. + properties: + kind: + type: string + enum: + - sequence + sequence: + type: integer + minimum: 0 + - type: object + additionalProperties: false + required: + - kind + - start_line + - end_line + description: Inclusive one-based lines in adapter-supplied text, + at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: + - line_range + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + anyOf: + - type: string + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded + in Unicode characters rather than UTF-8 bytes. + - type: 'null' + discriminator: + propertyName: kind + mapping: + sequence: '#/components/schemas/SourceSequenceLocator' + line_range: '#/components/schemas/SourceLineRangeLocator' + - type: 'null' artifact_refs: type: array items: diff --git a/integrations/opendal/README.md b/integrations/opendal/README.md index af95157d3d..3070c5a209 100644 --- a/integrations/opendal/README.md +++ b/integrations/opendal/README.md @@ -30,6 +30,9 @@ The process registers its immutable Definition manifest before each run. It adva after every accepted snapshot has a durable Server receipt and the scan completes without rejected or failed items. Set `POWERCONTEXT_TOKEN` when the Server requires bearer authentication. +The worker uses the frozen `TextEvidenceV1` projection when the runtime exports it. On pre-locator runtimes, +it uses the original `TextEvidence` schema instead, preserving the version-1 Definition manifest and fingerprint. + Snapshot identity is derived from the configured namespace, path, and content digest. Identical content is accepted idempotently; changed content creates a different immutable snapshot identity. This evaluation representation does not provide one logical Source with multiple observation IDs, current heads, deletion semantics, referenced diff --git a/integrations/opendal/src/powercontext_connector_opendal/source.py b/integrations/opendal/src/powercontext_connector_opendal/source.py index c089367cba..920719eccc 100644 --- a/integrations/opendal/src/powercontext_connector_opendal/source.py +++ b/integrations/opendal/src/powercontext_connector_opendal/source.py @@ -21,17 +21,19 @@ from pathlib import PurePosixPath from typing import Literal, cast +import powercontext.sources as source_models from powercontext.sources import ( TEXT_EVIDENCE_PROJECTION_KEY, MemoryEvidenceDeclaration, Source, SourceMaterialization, SourceProjection, - TextEvidence, ) from pydantic import BaseModel, JsonValue, field_validator TEXT_FILE_SNAPSHOT_SOURCE_NAME = "text-file-snapshot" +# Older runtimes expose the same frozen schema under TextEvidence alone. +_TEXT_EVIDENCE_MODEL: type[BaseModel] = getattr(source_models, "TextEvidenceV1", source_models.TextEvidence) class TextFileSnapshotCapture(BaseModel): @@ -138,9 +140,9 @@ class TextFileEvidenceProjection: name = TEXT_EVIDENCE_PROJECTION_KEY.name version = TEXT_EVIDENCE_PROJECTION_KEY.version source_class = TextFileSnapshotSource - output_class: type[BaseModel] = TextEvidence + output_class: type[BaseModel] = _TEXT_EVIDENCE_MODEL - def project(self, source: TextFileSnapshotSource, /) -> TextEvidence: + def project(self, source: TextFileSnapshotSource, /) -> BaseModel: metadata: dict[str, JsonValue] = { "namespace": source.namespace, "path": source.path, @@ -155,7 +157,7 @@ def project(self, source: TextFileSnapshotSource, /) -> TextEvidence: metadata["provider_version"] = source.provider_version if source.modified_at is not None: metadata["modified_at"] = source.modified_at.isoformat() - return TextEvidence( + return _TEXT_EVIDENCE_MODEL( source_type=TEXT_FILE_SNAPSHOT_SOURCE_NAME, source_id=source.name, content=source.content, diff --git a/integrations/opendal/tests/fixtures/text_file_manifest_before_locators.json b/integrations/opendal/tests/fixtures/text_file_manifest_before_locators.json new file mode 100644 index 0000000000..3ef06d1eba --- /dev/null +++ b/integrations/opendal/tests/fixtures/text_file_manifest_before_locators.json @@ -0,0 +1,166 @@ +{ + "name": "text-file-snapshot", + "version": "1", + "fingerprint": "sha256:83bd78af6a83b9d24ef5761cf5827a770c1edbb352b574d783869b5b338c299a", + "source_schema": { + "$defs": { + "SourceMaterialization": { + "description": "Describe where the value read for a Source comes from.", + "enum": [ + "captured", + "referenced" + ], + "title": "SourceMaterialization", + "type": "string" + } + }, + "description": "Captured text-file bytes with explicit filesystem provenance.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "definition_version": { + "default": "1", + "title": "Definition Version", + "type": "string" + }, + "materialization": { + "$ref": "#/$defs/SourceMaterialization" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "namespace": { + "title": "Namespace", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "media_type": { + "title": "Media Type", + "type": "string" + }, + "encoding": { + "const": "utf-8", + "title": "Encoding", + "type": "string" + }, + "content_digest": { + "title": "Content Digest", + "type": "string" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "etag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Etag" + }, + "provider_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider Version" + }, + "modified_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Modified At" + } + }, + "required": [ + "name", + "materialization", + "namespace", + "path", + "content", + "media_type", + "encoding", + "content_digest", + "size" + ], + "title": "TextFileSnapshotSource", + "type": "object" + }, + "projections": [ + { + "key": { + "name": "powercontext.text-evidence", + "version": "1" + }, + "schema": { + "$defs": { + "JsonValue": {} + }, + "description": "Canonical JSON shape consumed as textual Artifact evidence.", + "properties": { + "source_type": { + "title": "Source Type", + "type": "string" + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "metadata": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "title": "Metadata", + "type": "object" + } + }, + "required": [ + "source_type", + "source_id", + "content" + ], + "title": "TextEvidence", + "type": "object" + } + } + ] +} diff --git a/integrations/opendal/tests/test_opendal_connector.py b/integrations/opendal/tests/test_opendal_connector.py index 8af086fc4c..e93d11d34e 100644 --- a/integrations/opendal/tests/test_opendal_connector.py +++ b/integrations/opendal/tests/test_opendal_connector.py @@ -15,10 +15,14 @@ from __future__ import annotations import asyncio +import importlib.util +import json +import sys from pathlib import Path import httpx import opendalfs +import powercontext.sources as source_models import pytest from fastapi import FastAPI from powercontext.builtin.artifacts.atomic_memory.extraction import ( @@ -283,6 +287,36 @@ def test_text_file_definition_declares_its_executable_contract() -> None: assert len(definition.projections) == 1 +def test_worker_preserves_manifest_when_runtime_only_exports_legacy_text_evidence(monkeypatch) -> None: + """Simulate the pre-locator export surface; real installation checks are separate.""" + + legacy_model = source_models.TextEvidenceV1 + monkeypatch.setattr(source_models, "TextEvidence", legacy_model) + monkeypatch.delattr(source_models, "TextEvidenceV1") + source_path = Path(__file__).parents[1] / "src/powercontext_connector_opendal/source.py" + spec = importlib.util.spec_from_file_location("opendal_legacy_runtime_source", source_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, spec.name, module) + spec.loader.exec_module(module) + + definition = module.TEXT_FILE_SNAPSHOT_SOURCE_DEFINITION + expected = json.loads((Path(__file__).parent / "fixtures/text_file_manifest_before_locators.json").read_text()) + assert manifest_for_definition(definition).model_dump(mode="json", by_alias=True) == expected + + async def scenario(): + registry = SourceDefinitionRegistry((definition,)) + source = await registry.resolve( + module.TextFileSnapshotCapture(namespace="workspace", path="decision.md", content="保留旧版本兼容。") + ) + observation = project_source_for_transport(registry, source) + (projection,) = observation.projections + assert projection.value["content"] == "保留旧版本兼容。" + assert "locator" not in projection.value + + asyncio.run(scenario()) + + def test_remote_opendal_worker_completes_the_source_to_memory_loop(tmp_path: Path) -> None: async def scenario() -> None: filesystem = MemoryFileSystem() @@ -299,12 +333,57 @@ async def scenario() -> None: assert memory[0].content["schema"] == "powercontext.atomic-memory.v1" accepted_ref = result.items[0].source_ref assert accepted_ref is not None - assert [item.model_dump() for item in memory[0].sources] == [accepted_ref.model_dump()] + assert [item.model_dump() for item in memory[0].sources] == [ + accepted_ref.model_dump() | {"locator": {"kind": "sequence", "sequence": 1}} + ] assert accepted_ref.source_type == "text-file-snapshot" asyncio.run(scenario()) +def test_remote_opendal_worker_reuses_registered_pre_locator_definition(tmp_path: Path) -> None: + async def scenario() -> None: + database = tmp_path / "powercontext.db" + app = _app(database) + historical = HttpSourceDefinitionManifest.model_validate_json( + (Path(__file__).parent / "fixtures/text_file_manifest_before_locators.json").read_text() + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + assert ( + await client.register_source_definition(RegisterSourceDefinitionRequest(manifest=historical)) + == historical + ) + + filesystem = MemoryFileSystem() + filesystem.pipe_file("decision.md", "保留历史文件定义。".encode()) + connector = OpenDALTextFileConnector(filesystem, source_namespace="workspace-a") + result, memory = await _run(_app(database, memory=True), connector, flush_memory=True) + + assert result.status is ConnectorRunStatus.COMPLETE + assert result.items[0].status is ConnectorSubmissionStatus.ACCEPTED + assert memory is not None + assert [item.content["text"] for item in memory] == ["保留历史文件定义。"] + accepted_ref = result.items[0].source_ref + assert accepted_ref is not None + assert memory[0].sources[0].model_dump() == ( + accepted_ref.model_dump() | {"locator": {"kind": "sequence", "sequence": 1}} + ) + assert manifest_for_definition(TEXT_FILE_SNAPSHOT_SOURCE_DEFINITION).model_dump(mode="json", by_alias=True) == ( + historical.model_dump(mode="json", by_alias=True) + ) + + unchanged, _ = await _run(_app(database), connector) + assert unchanged.status is ConnectorRunStatus.COMPLETE + assert unchanged.previous_checkpoint == result.committed_checkpoint + assert unchanged.items == () + + asyncio.run(scenario()) + + def test_opendal_connector_reads_the_real_opendalfs_memory_backend(tmp_path: Path) -> None: async def scenario() -> None: filesystem = opendalfs.OpendalFileSystem( diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 021cc0ee23..d79b18c7f7 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -7621,6 +7621,9 @@ components: type: string minLength: 1 maxLength: 256 + locator: + $ref: "#/components/schemas/SourceLocator" + nullable: true ListDreamRunsRequest: type: object additionalProperties: false @@ -13564,6 +13567,48 @@ components: next_cursor: type: string nullable: true + SourceSequenceLocator: + type: object + additionalProperties: false + required: [kind, sequence] + description: Position of this exact Source in its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: [sequence] + sequence: + type: integer + minimum: 0 + SourceLineRangeLocator: + type: object + additionalProperties: false + required: [kind, start_line, end_line] + description: Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: [line_range] + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + type: string + nullable: true + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes. + SourceLocator: + oneOf: + - $ref: "#/components/schemas/SourceSequenceLocator" + - $ref: "#/components/schemas/SourceLineRangeLocator" + discriminator: + propertyName: kind + mapping: + sequence: "#/components/schemas/SourceSequenceLocator" + line_range: "#/components/schemas/SourceLineRangeLocator" SourceTypeReference: type: object additionalProperties: false @@ -13577,6 +13622,9 @@ components: minLength: 1 maxLength: 256 description: Source identity as accepted at ingestion, including Unicode and interior spaces. + locator: + $ref: "#/components/schemas/SourceLocator" + nullable: true SourceReference: type: object additionalProperties: false @@ -13587,6 +13635,9 @@ components: description: Stable Source type. source_id: type: string + locator: + $ref: "#/components/schemas/SourceLocator" + nullable: true CaptureStatus: type: string enum: [accepted] diff --git a/pyproject.toml b/pyproject.toml index 2a19381ecf..d9dc059753 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ keywords = ['python'] requires-python = ">=3.11,<4.0" dependencies = [ "jsonschema>=4.23,<5", - "pydantic>=2.10,<3", + "pydantic>=2.12,<3", "rfc8785>=0.1.4,<1", "typing-extensions>=4.12,<5", ] diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 68cdfbafd2..46f00aa9ee 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.0", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "02dc8322b0f3440903e3831a41daf43307f2ec2cc77d6ec9cc3e63b3d4c98889", - "rationale": "Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. 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. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum." + "persistence_sha256": "c41821f2640f9c9fa68f984ff5717fc0ab21a52f7e073d8ad636e8e6a9975b96", + "rationale": "Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. 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. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings." }, "cases": [], "automatic_sources": { diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 62f22fbe83..ea0d0a1b89 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -193,6 +193,7 @@ def _generate_models( evidence_models = _candidate_evidence_models(contract_schemas) source = _with_nested_model_defaults(f"{result.rstrip()}\n") source = _with_code_validation(source, contract_schemas) + source = _with_source_locator_validation(source, contract_schemas) source = _with_exclusive_fields(source, exclusive_schemas) source = _with_artifact_search_validation(source, contract_schemas) return _with_candidate_evidence_limits(source, evidence_models) @@ -340,6 +341,67 @@ def _validate_code_{kind}(self): return source +def _with_source_locator_validation(source: str, schemas: dict[str, Schema | Reference]) -> str: + """Keep Source positions bounded and absent locators byte-compatible with old references.""" + + for name, schema in schemas.items(): + if not isinstance(schema, Schema): + continue + is_range = (schema.model_extra or {}).get("x-powercontext-source-line-range") + field = "heading_path" if is_range else "locator" + if not is_range and name not in {"SourceReference", "SourceTypeReference", "DreamSourceReference"}: + continue + header = f"class {name}(BaseModel):" + start = source.find(header) + if start < 0: + raise ContractGenerationError("generated model class", name) # noqa: TRY003 + next_class = source.find("\nclass ", start + len(header)) + end = next_class if next_class >= 0 else len(source.rstrip()) + block = source[start:end] + lines = block.splitlines(keepends=True) + tree = ast.parse(block) + model = next(node for node in tree.body if isinstance(node, ast.ClassDef)) + declaration = next( + node + for node in model.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == field + ) + # The generator may wrap a field in Annotated/Field for its description or bounds. + # Only replace its default, retaining all generated constraints. + if declaration.value is None: + raise ContractGenerationError("generated model default", field) # noqa: TRY003 + line = declaration.value.lineno - 1 + old = lines[line] + lines[line] = ( + old[: declaration.value.col_offset] + + "Field(default=None, exclude_if=lambda value: value is None)" + + old[declaration.value.end_col_offset :] + ) + block = "".join(lines) + if is_range: + source = _with_model_validator_import(source) + # Import insertion changes offsets; locate the same class again. + start = source.index(header) + next_class = source.find("\nclass ", start + len(header)) + end = next_class if next_class >= 0 else len(source.rstrip()) + block = ( + block.rstrip() + + """ + + @model_validator(mode="after") + def _validate_source_line_range(self): + from powercontext.sources import SourceLineRangeLocator + + SourceLineRangeLocator.model_validate(self.model_dump(mode="json")) + return self + + +""" + ) + source = source[:start] + block + source[end:] + return source + + def _with_nested_model_defaults(source: str) -> str: """Construct typed nested defaults instead of assigning raw JSON to list[Model]. diff --git a/src/powercontext/__init__.py b/src/powercontext/__init__.py index f5676ec3e0..719775f1cf 100644 --- a/src/powercontext/__init__.py +++ b/src/powercontext/__init__.py @@ -64,10 +64,13 @@ SourceDefinition, SourceDefinitionRegistry, SourceEvidence, + SourceLineRangeLocator, + SourceLocator, SourceMaterialization, SourceProjection, SourceProjectionKey, SourceRef, + SourceSequenceLocator, SourceStore, ) from powercontext.triggers import PolicyTransition, Trigger @@ -114,12 +117,15 @@ "SourceDefinitionRegistry", "SourceError", "SourceEvidence", + "SourceLineRangeLocator", + "SourceLocator", "SourceMaterialization", "SourceNotFoundError", "SourceProjection", "SourceProjectionKey", "SourceProjectionNotFoundError", "SourceRef", + "SourceSequenceLocator", "SourceStore", "Sources", "Trigger", diff --git a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py index f75b49e6ab..61f91ba10c 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py @@ -36,7 +36,14 @@ ProcessingStage, attach_diagnostics, ) -from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, Source, SourceObservation, SourceRef, TextEvidence +from powercontext.sources import ( + TEXT_EVIDENCE_PROJECTION_KEY, + LocatedTextEvidence, + Source, + SourceObservation, + SourceRef, + SourceSequenceLocator, +) _JSON = TypeAdapter(JsonValue) _TEXT_EVIDENCE_KEY = TEXT_EVIDENCE_PROJECTION_KEY.model_dump(mode="json") @@ -299,23 +306,37 @@ async def project_atomic_memory_evidence( """Expose source-provided dates in content/metadata without synthesizing time.""" value = stored.value + locator = None body_from_text_projection = False if isinstance(value, SourceObservation): content = value.payload for projection in value.projections: if projection.key == TEXT_EVIDENCE_PROJECTION_KEY: - content = TextEvidence.model_validate(projection.value).model_dump(mode="json") + text_evidence = LocatedTextEvidence.model_validate(projection.value) + locator = text_evidence.locator + content = text_evidence.model_dump(mode="json") body_from_text_projection = True break else: materialized = await sources.read_value(value) + if isinstance(materialized, LocatedTextEvidence): + locator = materialized.locator content = ( materialized.model_dump(mode="json", by_alias=True) if isinstance(materialized, BaseModel) else materialized ) body = _JSON.validate_python(content) return AtomicMemoryEvidence( evidence_id=evidence_id, - source_ref=stored.ref, + source_ref=SourceRef( + source_type=stored.ref.source_type, + source_id=stored.ref.source_id, + # Journal positions belong to the persisted Source, not the adapter. + locator=( + SourceSequenceLocator(sequence=stored.journal_position) + if locator is None or isinstance(locator, SourceSequenceLocator) + else locator + ), + ), journal_position=stored.journal_position, content=body, source_metadata=_compact_source_metadata(value, body=body, body_from_text_projection=body_from_text_projection), diff --git a/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py b/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py index ef4352478a..1c98309caf 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py @@ -278,7 +278,7 @@ def apply( selected = tuple(evidence_by_id[identifier] for identifier in dict.fromkeys(output.evidence_ids)) sources = tuple( { - (item.source_ref.source_type, item.source_ref.source_id): item.source_ref + item.source_ref.model_dump_json(): item.source_ref for item in selected if isinstance(item, AtomicMemoryEvidence) and item.via_artifact is None }.values() diff --git a/src/powercontext/builtin/artifacts/merge.py b/src/powercontext/builtin/artifacts/merge.py index 087110f459..7a3b34cbe8 100644 --- a/src/powercontext/builtin/artifacts/merge.py +++ b/src/powercontext/builtin/artifacts/merge.py @@ -786,7 +786,7 @@ async def _commit_writes( # noqa: C901 - One boundary owns validation, persiste ) sources = system_sources[write.artifact_id] if sources: - unique = {(source.source_type, source.source_id): source for source in (*draft.sources, *sources)} + unique = {source.model_dump_json(): source for source in (*draft.sources, *sources)} draft = draft.model_copy(update={"sources": tuple(unique.values())}) validated = await self._validate_draft( connection, scope_id, target, draft, context, system_sources=sources, source_read_set=source_read_set diff --git a/src/powercontext/builtin/evidence/resolver.py b/src/powercontext/builtin/evidence/resolver.py index d2592d1363..4ab970db0c 100644 --- a/src/powercontext/builtin/evidence/resolver.py +++ b/src/powercontext/builtin/evidence/resolver.py @@ -476,7 +476,7 @@ async def _read_source( identity = evidence_id(ref) if eligible: attested = None if self.root_identity is None else self.root_identity(ref, source) - group_key = reference_key(ref) if attested is None else attested + group_key = ref.model_dump_json(exclude={"locator"}) if attested is None else attested state.groups[identity] = ("root_" + content_digest(group_key.encode())[7:31], attested is not None) return ( EvidenceNode( diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 8456aa5cbb..16221a023e 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -21,7 +21,7 @@ from datetime import UTC, datetime from typing import Any -from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError +from pydantic import BaseModel, ConfigDict, JsonValue, RootModel, ValidationError from sqlalchemy import and_, insert, literal, select, true, tuple_, union_all, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection, AsyncTransaction @@ -55,6 +55,7 @@ ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_PUBLICATIONS_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACTS_TABLE, ) from powercontext.builtin.source_eligibility import ArtifactLineageTarget, require_source_eligible @@ -63,7 +64,11 @@ RevisionConflictError, ) from powercontext.limits import MAX_SCOPE_ID_LENGTH -from powercontext.sources import SourceRef +from powercontext.sources import SourceLocator, SourceRef + + +class _StoredSourceLocator(RootModel[SourceLocator]): + pass def with_artifact_content(statement: Select[Any], heads: FromClause = ARTIFACT_HEADS_TABLE) -> Select[Any]: @@ -752,7 +757,7 @@ async def _insert_revisions( scope_id: str, revisions: Sequence[tuple[type[Artifact[Any]], ArtifactRef, object, ArtifactLineage]], ) -> tuple[Artifact[Any], ...]: - bodies, sources, artifact_refs, artifacts = [], [], [], [] + bodies, sources, source_locators, artifact_refs, artifacts = [], [], [], [], [] for artifact_type, ref, content, lineage in revisions: if not isinstance(content, BaseModel): raise TypeError("artifact content must be a BaseModel") # noqa: TRY003 @@ -774,6 +779,15 @@ async def _insert_revisions( {**identity, "ordinal": ordinal, "source_type": source.source_type, "source_id": source.source_id} for ordinal, source in enumerate(lineage.sources) ) + source_locators.extend( + { + **identity, + "ordinal": ordinal, + "locator": dump_model(_StoredSourceLocator(source.locator), kind="source", name="locator"), + } + for ordinal, source in enumerate(lineage.sources) + if source.locator is not None + ) artifact_refs.extend( { **identity, @@ -794,6 +808,8 @@ async def _insert_revisions( await connection.execute(insert(ARTIFACTS_TABLE), bodies) if sources: await connection.execute(insert(ARTIFACT_LINEAGE_SOURCES_TABLE), sources) + if source_locators: + await connection.execute(insert(ARTIFACT_SOURCE_LOCATORS_TABLE), source_locators) if artifact_refs: await connection.execute(insert(ARTIFACT_LINEAGE_ARTIFACTS_TABLE), artifact_refs) return tuple(artifacts) @@ -844,6 +860,15 @@ async def _load_lineage_many( ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c, ARTIFACT_PUBLICATIONS_TABLE.c, ) + located_sources = ARTIFACT_LINEAGE_SOURCES_TABLE.outerjoin( + ARTIFACT_SOURCE_LOCATORS_TABLE, + and_( + *( + source[name] == ARTIFACT_SOURCE_LOCATORS_TABLE.c[name] + for name in ("scope_id", "family", "artifact_id", "revision", "ordinal") + ) + ), + ) columns = ( "relation", "family", @@ -855,21 +880,33 @@ async def _load_lineage_many( "ref_id", "ref_revision", "digest", + "locator", ) - def project(relation, table, *, prefix="", **references): + def project(relation, table, *, prefix="", from_clause=None, **references): fields = { "relation": literal(relation), **{name: table[prefix + name] for name in ("family", "artifact_id", "revision")}, **references, } - return select(*(fields.get(name, literal(None)).label(name) for name in columns)).where( + statement = select(*(fields.get(name, literal(None)).label(name) for name in columns)) + if from_clause is not None: + statement = statement.select_from(from_clause) + return statement.where( table[prefix + "scope_id"] == scope_id, tuple_(*(table[prefix + name] for name in ("family", "artifact_id", "revision"))).in_(keys), ) relations = union_all( - project("source", source, ordinal=source.ordinal, ref_family=source.source_type, ref_id=source.source_id), + project( + "source", + source, + ordinal=source.ordinal, + ref_family=source.source_type, + ref_id=source.source_id, + from_clause=located_sources, + locator=ARTIFACT_SOURCE_LOCATORS_TABLE.c.locator, + ), project( "artifact", artifact, @@ -907,7 +944,23 @@ def project(relation, table, *, prefix="", **references): for row in rows: key = (str(row["family"]), str(row["artifact_id"]), int(row["revision"])) if row["relation"] == "source": - sources[key].append(SourceRef(source_type=str(row["ref_family"]), source_id=str(row["ref_id"]))) + locator = ( + None + if row["locator"] is None + else load_model( + _StoredSourceLocator, + stored_bytes(row["locator"], column="locator"), + kind="source", + name="locator", + ).root + ) + sources[key].append( + SourceRef( + source_type=str(row["ref_family"]), + source_id=str(row["ref_id"]), + locator=locator, + ) + ) elif row["relation"] == "artifact": artifacts[key].append( ArtifactRef( @@ -1093,6 +1146,7 @@ async def _validate_lineage_sources( stored = None if source_read_set is None else source_read_set.find(connection, scope_id, source_ref) if stored is None: stored = fetched[(source_ref.source_type, source_ref.source_id)] + stored.validate_locator(source_ref) require_source_eligible( source_ref, stored.value, diff --git a/src/powercontext/builtin/persistence/migrations/atomic_projection.py b/src/powercontext/builtin/persistence/migrations/atomic_projection.py index c76ec7f8cc..86bebdab30 100644 --- a/src/powercontext/builtin/persistence/migrations/atomic_projection.py +++ b/src/powercontext/builtin/persistence/migrations/atomic_projection.py @@ -35,6 +35,7 @@ ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_TAGS_TABLE, ARTIFACTS_TABLE, ) @@ -135,6 +136,7 @@ def _rows_digest(self, connection, current): ARTIFACT_HEADS_TABLE, ARTIFACT_TAGS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, current, ATOMIC_MEMORY_EVIDENCE_HISTORY_TABLE, diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index aadd20d129..b6ec054ab1 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -44,6 +44,10 @@ "v1_5_0_003": [ "schemas/sqlite-v1_5_0_003.json", "schemas/mysql-v1_5_0_003.json" + ], + "v1_5_0_004": [ + "schemas/sqlite-v1_5_0_004.json", + "schemas/mysql-v1_5_0_004.json" ] }, "sqlite_baselines": { @@ -79,6 +83,9 @@ ], "v1_5_0_003": [ "357287e3bae474ca1810dd0949fe56326dff48f5adf0db851b4b4cf3cf01a308" + ], + "v1_5_0_004": [ + "cfafc8c1d4f0207e5d04febca1e3f9f9a91948d15fb85678de3765d8e772efdc" ] }, "affected_objects": [ @@ -124,6 +131,7 @@ "pc_artifact_search_fts_update", "pc_artifact_search_states", "pc_artifact_search_text", + "pc_artifact_source_locators", "pc_artifact_tags", "pc_artifact_vector_indexes", "pc_artifact_vector_spaces", @@ -323,6 +331,11 @@ "artifact_storage", "portable_storage", "atomic_memory_evidence_projection" + ], + "v1_5_0_004": [ + "artifact_storage", + "portable_storage", + "atomic_memory_evidence_projection" ] }, "retained_fingerprints": { @@ -343,7 +356,8 @@ "schemas/mysql-v1_4_1_006.json", "schemas/mysql-v1_5_0_001.json", "schemas/mysql-v1_5_0_002.json", - "schemas/mysql-v1_5_0_003.json" + "schemas/mysql-v1_5_0_003.json", + "schemas/mysql-v1_5_0_004.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_004.py b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_004.py new file mode 100644 index 0000000000..03b0f959ca --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_004.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 Source position metadata through the shared DDL executor.""" + +from alembic import context + +revision = "v1_5_0_004" +down_revision = "v1_5_0_003" + + +def upgrade() -> None: + context.config.attributes["execute_revision"](revision) diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json new file mode 100644 index 0000000000..ef60fc24e2 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_004.json @@ -0,0 +1,141 @@ +{ + "format": "powercontext.mysql-state-layouts.v1", + "revisions": { + "v1_5_0_004": { + "statements": [ + "CREATE TABLE pc_artifact_source_locators (\n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tfamily VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tartifact_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\trevision INTEGER NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tlocator MEDIUMBLOB NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision, ordinal), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision, ordinal) REFERENCES pc_artifact_lineage_sources (scope_id, family, artifact_id, revision, ordinal) ON DELETE CASCADE\n)" + ], + "changes": [ + { + "pc_artifact_source_locators": { + "columns": { + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "family": { + "type": [ + "varchar", + [ + "128" + ] + ], + "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": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "ordinal": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "locator": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "scope_id", + null, + "asc" + ], + [ + "family", + null, + "asc" + ], + [ + "artifact_id", + null, + "asc" + ], + [ + "revision", + null, + "asc" + ], + [ + "ordinal", + null, + "asc" + ] + ], + "foreign_keys": [ + { + "columns": [ + "scope_id", + "family", + "artifact_id", + "revision", + "ordinal" + ], + "referred_schema": null, + "referred_table": "pc_artifact_lineage_sources", + "referred_columns": [ + "scope_id", + "family", + "artifact_id", + "revision", + "ordinal" + ], + "ondelete": "cascade", + "onupdate": "restrict" + } + ], + "checks": {}, + "indexes": {} + } + } + ] + } + } +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_004.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_004.json new file mode 100644 index 0000000000..7eb7dced4f --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_004.json @@ -0,0 +1,3 @@ +{ + "create": "CREATE TABLE pc_artifact_source_locators (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tlocator BLOB NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision, ordinal), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision, ordinal) REFERENCES pc_artifact_lineage_sources (scope_id, family, artifact_id, revision, ordinal) ON DELETE CASCADE\n)" +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_004_source_locators.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_004_source_locators.py new file mode 100644 index 0000000000..b191edf7d9 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_004_source_locators.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 position metadata for exact Source lineage edges.""" + +import json +from pathlib import Path + +from alembic import op + +revision = "v1_5_0_004" +down_revision = "v1_5_0_003" + + +def upgrade(): + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_5_0_004.json" + op.get_bind().exec_driver_sql(json.loads(resource.read_text(encoding="utf-8"))["create"]) 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 4d8bd0a7bc..f02495e3a8 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 @@ -43,6 +43,10 @@ "v1_5_0_003": [ "schemas/sqlite-v1_5_0_003.json", "schemas/mysql-v1_5_0_003.json" + ], + "v1_5_0_004": [ + "schemas/sqlite-v1_5_0_004.json", + "schemas/mysql-v1_5_0_004.json" ] }, "sqlite_baselines": { @@ -78,6 +82,9 @@ ], "v1_5_0_003": [ "357287e3bae474ca1810dd0949fe56326dff48f5adf0db851b4b4cf3cf01a308" + ], + "v1_5_0_004": [ + "cfafc8c1d4f0207e5d04febca1e3f9f9a91948d15fb85678de3765d8e772efdc" ] }, "affected_objects": [ @@ -123,6 +130,7 @@ "pc_artifact_search_fts_update", "pc_artifact_search_states", "pc_artifact_search_text", + "pc_artifact_source_locators", "pc_artifact_tags", "pc_artifact_vector_indexes", "pc_artifact_vector_spaces", @@ -327,6 +335,11 @@ "artifact_storage", "portable_storage", "atomic_memory_evidence_projection" + ], + "v1_5_0_004": [ + "artifact_storage", + "portable_storage", + "atomic_memory_evidence_projection" ] }, "retained_fingerprints": { @@ -347,7 +360,8 @@ "schemas/mysql-v1_4_1_006.json", "schemas/mysql-v1_5_0_001.json", "schemas/mysql-v1_5_0_002.json", - "schemas/mysql-v1_5_0_003.json" + "schemas/mysql-v1_5_0_003.json", + "schemas/mysql-v1_5_0_004.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/sources.py b/src/powercontext/builtin/persistence/sources.py index baa01ff972..3e77013710 100644 --- a/src/powercontext/builtin/persistence/sources.py +++ b/src/powercontext/builtin/persistence/sources.py @@ -56,7 +56,7 @@ ) from powercontext.builtin.sources.content import CONTENT_SOURCE_NAME, ContentSource from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME -from powercontext.errors import SourceDefinitionNotFoundError +from powercontext.errors import InvalidSourceReferenceError, SourceDefinitionNotFoundError from powercontext.limits import MAX_SCOPE_ID_LENGTH from powercontext.sources import ( MemoryEvidenceDeclaration, @@ -67,6 +67,7 @@ SourceEvidence, SourceObservation, SourceRef, + SourceSequenceLocator, ) from powercontext.sources.evidence import log_evidence_neutralized @@ -113,6 +114,12 @@ class StoredSource(BaseModel): value: Source journal_position: int + def validate_locator(self, ref: SourceRef, /) -> None: + """Reject a position that names another journal entry instead of redirecting.""" + + if isinstance(ref.locator, SourceSequenceLocator) and ref.locator.sequence != self.journal_position: + raise InvalidSourceReferenceError("locator.sequence", "does not match the cited Source journal position") + @dataclass(frozen=True, slots=True) class _SourceReadSet: @@ -275,7 +282,9 @@ async def get( row = await self._find_row(connection, scope_id, ref) if row is None: raise RepositoryNotFoundError("source", (scope_id, ref)) - return self.decode_source(row) + stored = self.decode_source(row) + stored.validate_locator(ref) + return stored async def get_many( self, @@ -301,6 +310,8 @@ async def get_many( for ref in ordered: if (ref.source_type, ref.source_id) not in decoded: raise RepositoryNotFoundError("source", (scope_id, ref)) + for ref in refs: + decoded[(ref.source_type, ref.source_id)].validate_locator(ref) return tuple(decoded[(ref.source_type, ref.source_id)] for ref in ordered) async def _existing_many( diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 226e8d9ac1..6068aae67a 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -295,6 +295,29 @@ def _entry_text_type(): ), ) +ARTIFACT_SOURCE_LOCATORS_TABLE = Table( + "pc_artifact_source_locators", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), + Column("revision", Integer, primary_key=True), + Column("ordinal", Integer, primary_key=True), + Column("locator", _canonical_payload_type(), nullable=False), + ForeignKeyConstraint( + ("scope_id", "family", "artifact_id", "revision", "ordinal"), + ( + "pc_artifact_lineage_sources.scope_id", + "pc_artifact_lineage_sources.family", + "pc_artifact_lineage_sources.artifact_id", + "pc_artifact_lineage_sources.revision", + "pc_artifact_lineage_sources.ordinal", + ), + ondelete="CASCADE", + ), +) + + ARTIFACT_LINEAGE_ARTIFACTS_TABLE = Table( "pc_artifact_lineage_artifacts", SHARED_METADATA, @@ -1089,6 +1112,7 @@ def _entry_text_type(): ARTIFACT_HANDLES_TABLE, ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_PUBLICATIONS_TABLE, CANDIDATE_VERSIONS_TABLE, diff --git a/src/powercontext/builtin/portability/bundle.py b/src/powercontext/builtin/portability/bundle.py index bdda979a6e..e4094e1682 100644 --- a/src/powercontext/builtin/portability/bundle.py +++ b/src/powercontext/builtin/portability/bundle.py @@ -44,7 +44,7 @@ from typing import Any, Literal, cast from uuid import NAMESPACE_URL, UUID, uuid5 -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from sqlalchemy import ( BigInteger, DateTime, @@ -93,6 +93,7 @@ ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_PUBLICATIONS_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_TAGS_TABLE, ARTIFACTS_TABLE, CANDIDATE_HEADS_TABLE, @@ -121,7 +122,7 @@ ) from powercontext.builtin.tags import normalize_tags from powercontext.errors import SourceError -from powercontext.sources import SourceDefinitionManifest, SourceObservation +from powercontext.sources import SourceDefinitionManifest, SourceLocator, SourceObservation, SourceRef from powercontext.sources.evidence import MemoryEvidenceDeclaration, SourceEvidence FORMAT_VERSION = 1 @@ -179,6 +180,7 @@ "artifact_revision", "artifact_handle", "artifact_lineage_source", + "artifact_source_locator", "artifact_lineage_artifact", "artifact_publication", "artifact_head", @@ -352,6 +354,11 @@ class _RecordSpec: ("scope_id", "family", "artifact_id", "revision", "ordinal"), ("source_type", "source_id"), ), + "artifact_source_locator": _RecordSpec( + ARTIFACT_SOURCE_LOCATORS_TABLE, + ("scope_id", "family", "artifact_id", "revision", "ordinal"), + ("locator",), + ), "artifact_lineage_artifact": _RecordSpec( ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ("scope_id", "family", "artifact_id", "revision", "ordinal"), @@ -1582,6 +1589,12 @@ def bytes_values(*fields: str, nullable: tuple[str, ...] = ()) -> None: raise BundleFormatError("artifact handle is invalid") from error elif kind == "artifact_lineage_source": strings("source_type", "source_id") + elif kind == "artifact_source_locator": + bytes_values("locator") + try: + TypeAdapter(SourceLocator).validate_python(_decoded_bytes_json(payload["locator"], "Source locator")) + except ValidationError as error: + raise BundleFormatError("Source locator has invalid domain values") from error elif kind == "artifact_lineage_artifact": strings("upstream_family", "upstream_artifact_id") integers("upstream_revision", positive=True) @@ -2193,6 +2206,9 @@ def _validate_record_dependencies( # noqa: C901 - logical record kinds have dis {"scope_id": scope, "memory_artifact_id": identity["artifact_id"], "entry_id": identity["target_id"]}, ): raise BundleFormatError("artifact tag references missing memory entry") + elif record.record_type == "artifact_source_locator": + if not contains("artifact_lineage_source", identity): + raise BundleFormatError("Source locator references missing lineage") elif record.record_type == "artifact_lineage_source": if not contains( "artifact_revision", @@ -2605,6 +2621,15 @@ def _reference_items(value: object, kind: Literal["source", "artifact"], /) -> t fields = {"source_type", "source_id"} if kind == "source" else {"family", "artifact_id", "revision"} references: list[Mapping[str, object]] = [] for item in decoded: + if kind == "source": + if not isinstance(item, dict) or set(item) not in (fields, fields | {"locator"}): + raise BundleFormatError("source reference has invalid fields") + try: + SourceRef.model_validate(item, strict=True) + except ValidationError as error: + raise BundleFormatError("source reference has invalid domain values") from error + references.append(cast(Mapping[str, object], item)) + continue if ( not isinstance(item, dict) or set(item) != fields @@ -2624,6 +2649,7 @@ def _required_artifact_families(records: Iterable[_Record]) -> set[str]: "artifact_revision", "artifact_handle", "artifact_lineage_source", + "artifact_source_locator", "artifact_lineage_artifact", "artifact_head", "topic_memory_publication", diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index 412b03233d..9ef8a2fd9e 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -1391,10 +1391,10 @@ def _candidate_draft(candidate: ReviewedCandidate) -> ReviewedDraft: def _unique_sources(values: tuple[SourceRef, ...]) -> tuple[SourceRef, ...]: - seen: set[tuple[str, str]] = set() + seen: set[str] = set() result: list[SourceRef] = [] for value in values: - key = (value.source_type, value.source_id) + key = value.model_dump_json() if key not in seen: seen.add(key) result.append(value) diff --git a/src/powercontext/builtin/runtime/atomic_memory_rebuild.py b/src/powercontext/builtin/runtime/atomic_memory_rebuild.py index dd26ae25bb..09cfe81ab1 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_rebuild.py +++ b/src/powercontext/builtin/runtime/atomic_memory_rebuild.py @@ -28,7 +28,12 @@ from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemory, AtomicMemoryRecord, AtomicMemoryStateValue +from powercontext.builtin.artifacts.atomic_memory.models import ( + AtomicMemory, + AtomicMemoryContent, + AtomicMemoryRecord, + AtomicMemoryStateValue, +) from powercontext.builtin.artifacts.memory.canonical import canonical_json from powercontext.builtin.artifacts.search import analyze_text from powercontext.builtin.inference import EmbeddingModel @@ -60,7 +65,12 @@ from powercontext.builtin.persistence.oceanbase.atomic_memory_index import OceanBaseAtomicMemoryIndex from powercontext.builtin.persistence.revision_metadata_schema import ensure_artifact_revision_metadata_schema from powercontext.builtin.persistence.sources import SourceRepository -from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, ARTIFACTS_TABLE +from powercontext.builtin.persistence.tables import ( + ARTIFACT_HEADS_TABLE, + ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACTS_TABLE, +) +from powercontext.sources import SourceRef class AtomicMemoryProjectionRebuildReport(BaseModel): @@ -138,9 +148,7 @@ async def _evidence_history( evidence = AtomicMemoryEvidenceRepository() after: tuple[str, str, int] | None = None while True: - statement = select(table.c.scope_id, table.c.artifact_id, table.c.revision).where( - table.c.family == AtomicMemory.family - ) + statement = select(table).where(table.c.family == AtomicMemory.family) if selected_scope is not None: statement = statement.where(table.c.scope_id == selected_scope) if after is not None: @@ -154,13 +162,43 @@ async def _evidence_history( break # Rows are ordered by scope, so one page holds contiguous per-scope groups. for scope_id, group in groupby(rows, key=lambda row: str(row.scope_id)): - references = tuple( - ArtifactRef(family=AtomicMemory.family, artifact_id=str(row.artifact_id), revision=int(row.revision)) - for row in group + revisions = tuple(group) + lineage = ARTIFACT_LINEAGE_SOURCES_TABLE + keys = tuple((str(row.artifact_id), int(row.revision)) for row in revisions) + source_rows = ( + ( + await connection.execute( + select(lineage) + .where( + lineage.c.scope_id == scope_id, + lineage.c.family == AtomicMemory.family, + tuple_(lineage.c.artifact_id, lineage.c.revision).in_(keys), + ) + .order_by(lineage.c.artifact_id, lineage.c.revision, lineage.c.ordinal) + ) + ) + .mappings() + .all() ) - loaded = await artifacts.get_many(connection, scope_id, references) - for reference, artifact in zip(references, loaded, strict=True): - yield await evidence.verify(connection, scope_id, reference, artifact.lineage.sources, sources=sources) + by_revision: dict[tuple[str, int], list[SourceRef]] = {} + for row in source_rows: + by_revision.setdefault((str(row["artifact_id"]), int(row["revision"])), []).append( + SourceRef(source_type=row["source_type"], source_id=row["source_id"]) + ) + for row in revisions: + reference = ArtifactRef( + family=AtomicMemory.family, artifact_id=str(row.artifact_id), revision=int(row.revision) + ) + AtomicMemoryContent.model_validate(artifacts.decode_content(row._mapping)) + # Evidence binds exact Source identities, independently of citation + # positions introduced by a later schema revision. + yield await evidence.verify( + connection, + scope_id, + reference, + by_revision.get((reference.artifact_id, reference.revision), ()), + sources=sources, + ) after = scope_id, reference.artifact_id, reference.revision @@ -243,9 +281,33 @@ async def _record( state = await state_repository.get(connection, scope_id, artifact_id) if for_update: await state_repository.require_summary(connection, scope_id, artifact_id, state) - artifact = await artifacts.latest(connection, scope_id, AtomicMemory.family, artifact_id) - if not isinstance(artifact, AtomicMemory): - raise AtomicMemoryIndexError("authority-content", "Atomic Memory has an unsupported authoritative content type") + # Projection repair needs only exact content and authoritative state. Historical + # migration checkpoints must not depend on lineage tables added by later steps. + heads, bodies = ARTIFACT_HEADS_TABLE, ARTIFACTS_TABLE + statement = ( + select(heads.c.artifact_id, heads.c.family, heads.c.revision, bodies.c.content) + .select_from( + heads.outerjoin( + bodies, + and_(*(heads.c[name] == bodies.c[name] for name in ("scope_id", "family", "artifact_id", "revision"))), + ) + ) + .where( + heads.c.scope_id == scope_id, + heads.c.family == AtomicMemory.family, + heads.c.artifact_id == artifact_id, + ) + ) + if for_update: + statement = statement.with_for_update() + row = (await connection.execute(statement)).mappings().one_or_none() + if row is None or row["content"] is None: + raise AtomicMemoryIndexError("authority-content", "Atomic Memory head has no authoritative content") + artifact = AtomicMemory( + artifact_id=str(row["artifact_id"]), + revision=int(row["revision"]), + content=AtomicMemoryContent.model_validate(artifacts.decode_content(row)), + ) return AtomicMemoryRecord(artifact=artifact, state=state) diff --git a/src/powercontext/builtin/sources/content.py b/src/powercontext/builtin/sources/content.py index c9d40a380c..94afaf4891 100644 --- a/src/powercontext/builtin/sources/content.py +++ b/src/powercontext/builtin/sources/content.py @@ -23,7 +23,7 @@ from powercontext.artifacts import ArtifactRef from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH -from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, AdapterSourceDefinition, TextEvidence +from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, AdapterSourceDefinition, TextEvidenceV1 from powercontext.sources.models import Source, SourceMaterialization CONTENT_SOURCE_NAME = "content" @@ -161,10 +161,10 @@ class ContentTextEvidenceProjection: name = TEXT_EVIDENCE_PROJECTION_KEY.name version = TEXT_EVIDENCE_PROJECTION_KEY.version source_class = ContentSource - output_class: type[BaseModel] = TextEvidence + output_class: type[BaseModel] = TextEvidenceV1 - def project(self, source: ContentSource, /) -> TextEvidence: - return TextEvidence( + def project(self, source: ContentSource, /) -> TextEvidenceV1: + return TextEvidenceV1( source_type=CONTENT_SOURCE_NAME, source_id=source.name, content=source.content, diff --git a/src/powercontext/builtin/sources/observation_validation.py b/src/powercontext/builtin/sources/observation_validation.py index 2145b55b60..ecef73af47 100644 --- a/src/powercontext/builtin/sources/observation_validation.py +++ b/src/powercontext/builtin/sources/observation_validation.py @@ -26,7 +26,13 @@ from powercontext.errors import InvalidSourceDefinitionError, InvalidSourceObservationError from powercontext.limits import MAX_SOURCE_OBSERVATION_BYTES -from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, SourceDefinitionManifest, SourceObservation, TextEvidence +from powercontext.sources import ( + TEXT_EVIDENCE_PROJECTION_KEY, + LocatedTextEvidence, + SourceDefinitionManifest, + SourceObservation, + TextEvidence, +) def validate_source_definition_manifest(manifest: SourceDefinitionManifest, /) -> None: @@ -35,10 +41,13 @@ def validate_source_definition_manifest(manifest: SourceDefinitionManifest, /) - if len(manifest.model_dump_json(by_alias=True).encode()) > 64 * 1024: raise InvalidSourceDefinitionError(type(manifest), "manifest", "must not exceed 64 KiB") json_schema_validator(manifest.name, manifest.source_schema) - standard_text_schema = TextEvidence.model_json_schema() + standard_text_schema = LocatedTextEvidence.model_json_schema() for projection in manifest.projections: json_schema_validator(projection.key.name, projection.schema_) - if projection.key == TEXT_EVIDENCE_PROJECTION_KEY and projection.schema_ != standard_text_schema: + if projection.key == TEXT_EVIDENCE_PROJECTION_KEY and projection.schema_ not in ( + standard_text_schema, + TextEvidence.model_json_schema(), + ): raise InvalidSourceDefinitionError( type(manifest), "projection", f"{projection.key.name!r} must use the standard schema" ) @@ -62,7 +71,7 @@ def validate_source_observation(source: SourceObservation, manifest: SourceDefin value = supplied[key] _validate_schema_value(key.name, declaration.schema_, value) if key == TEXT_EVIDENCE_PROJECTION_KEY: - evidence = TextEvidence.model_validate(value) + evidence = LocatedTextEvidence.model_validate(value) if evidence.source_type != source.source_type or evidence.source_id != source.name: raise InvalidSourceObservationError( "text-evidence", "source identity does not match the observation envelope" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 6f3e03a9a0..c504b6bb22 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -455,6 +455,8 @@ SkillValidationItem, SourceDefinitionManifest, SourceInventoryStatistics, + SourceLineRangeLocator, + SourceLocator, SourceObservation, SourceObservationReceipt, SourcePage, @@ -463,6 +465,7 @@ SourceProjectionValue, SourceRecord, SourceReference, + SourceSequenceLocator, SourceType, SourceTypeReference, StatsPeriod, @@ -949,6 +952,8 @@ "SkillValidationItem", "SourceDefinitionManifest", "SourceInventoryStatistics", + "SourceLineRangeLocator", + "SourceLocator", "SourceObservation", "SourceObservationReceipt", "SourcePage", @@ -957,6 +962,7 @@ "SourceProjectionValue", "SourceRecord", "SourceReference", + "SourceSequenceLocator", "SourceType", "SourceTypeReference", "StatsPeriod", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 9ab1e54ddb..8eafeaf718 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -562,14 +562,6 @@ class DreamEvidenceIndependence(StrEnum): UNKNOWN = "unknown" -class DreamSourceReference(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - source_type: Annotated[StrictStr, Field(max_length=128, min_length=1)] - source_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - class ListDreamRunsRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -625,28 +617,6 @@ class DreamEvidenceEdge(BaseModel): upstream_id: StrictStr -class DreamEvidenceNode(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - evidence_id: StrictStr - kind: DreamEvidenceKind - digest: StrictStr - source: DreamSourceReference | None = None - artifact: ArtifactReference | None = None - role: DreamEvidenceRole - historical: StrictBool = False - - -class DreamRootEvidenceGroup(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - group_id: StrictStr - sources: list[DreamSourceReference] - independence: DreamEvidenceIndependence = DreamEvidenceIndependence.UNKNOWN - - class CandidateKind(StrEnum): ARTIFACT = "artifact" TAG = "tag" @@ -3433,6 +3403,49 @@ class ReplaceMemoryArtifactEntry(BaseModel): text: Annotated[StrictStr, Field(max_length=8192, min_length=1, pattern=".*\\S.*")] +class Kind10(StrEnum): + SEQUENCE = "sequence" + + +class SourceSequenceLocator(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: Literal["sequence"] + sequence: Annotated[StrictInt, Field(ge=0)] + + +class Kind11(StrEnum): + LINE_RANGE = "line_range" + + +class SourceLineRangeLocator(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: Literal["line_range"] + start_line: Annotated[StrictInt, Field(ge=1)] + end_line: Annotated[StrictInt, Field(ge=1)] + heading_path: Annotated[ + StrictStr | None, + Field( + description="Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes.", + max_length=1024, + ), + ] = Field(default=None, exclude_if=lambda value: value is None) + + @model_validator(mode="after") + def _validate_source_line_range(self): + from powercontext.sources import SourceLineRangeLocator + + SourceLineRangeLocator.model_validate(self.model_dump(mode="json")) + return self + + +class SourceLocator(RootModel[SourceSequenceLocator | SourceLineRangeLocator]): + root: Annotated[SourceSequenceLocator | SourceLineRangeLocator, Field(discriminator="kind")] + + class SourceTypeReference(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3448,6 +3461,7 @@ class SourceTypeReference(BaseModel): min_length=1, ), ] + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) class SourceReference(BaseModel): @@ -3456,6 +3470,7 @@ class SourceReference(BaseModel): ) name: Annotated[StrictStr, Field(description="Stable Source type.")] source_id: StrictStr + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) class CaptureStatus(StrEnum): @@ -4228,7 +4243,7 @@ class Availability(StrEnum): UNAVAILABLE = "unavailable" -class Kind10(StrEnum): +class Kind12(StrEnum): COLLECT = "collect" WRITE = "write" @@ -4246,7 +4261,7 @@ class SourceManagementReceipt(BaseModel): capability_version: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None availability: Availability | None = None updated_at: StrictStr | None = None - kind: Kind10 | None = None + kind: Kind12 | None = None enabled: StrictBool | None = None source_refs: Annotated[ list[SourceTypeReference] | None, @@ -4839,71 +4854,47 @@ class HandoffReceiptIdentity(BaseModel): receiver_identity_matches: StrictBool -class CreateDreamRunRequest(BaseModel): +class DreamSourceReference(BaseModel): model_config = ConfigDict( extra="forbid", ) - tag_target: TagDreamTarget | None = None - operation: DreamOperation - artifacts: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] - sources: Annotated[list[DreamSourceReference], Field(validate_default=True)] = [] - target: ArtifactReference | None = None - idempotency_key: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^\\S(?:[\\s\\S]*\\S)?$")] + source_type: Annotated[StrictStr, Field(max_length=128, min_length=1)] + source_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) -class DreamInputManifest(BaseModel): +class CreateDreamRunRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - transform_version: StrictStr = "powercontext.dream.evidence.v1" + tag_target: TagDreamTarget | None = None + operation: DreamOperation artifacts: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] sources: Annotated[list[DreamSourceReference], Field(validate_default=True)] = [] - nodes: Annotated[list[DreamEvidenceNode], Field(validate_default=True)] = [] - edges: Annotated[list[DreamEvidenceEdge], Field(validate_default=True)] = [] - root_groups: Annotated[list[DreamRootEvidenceGroup], Field(validate_default=True)] = [] - projection_digest: StrictStr - projection_bytes: StrictInt - incomplete: StrictBool = False + target: ArtifactReference | None = None + idempotency_key: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^\\S(?:[\\s\\S]*\\S)?$")] -class DreamRun(BaseModel): +class DreamEvidenceNode(BaseModel): model_config = ConfigDict( extra="forbid", ) - tag_target: TagDreamTarget | None = None - reused: StrictBool = False - scope_id: StrictStr - run_id: StrictStr - operation: DreamOperation - status: DreamStatus = DreamStatus.QUEUED - outcome: DreamOutcome | None = None - target: ArtifactReference | None = None - candidate: DreamCandidateRef | None = None - reason: StrictStr | None = None - error: StrictStr | None = None - accepted_at: AwareDatetime - started_at: AwareDatetime | None = None - completed_at: AwareDatetime | None = None - attempt_count: StrictInt = 0 - input_manifest: DreamInputManifest | None = None - usage: DreamUsage | None = None - budget: DreamBudget | None = None - prompt_version: StrictStr = "powercontext.dream.v1.4" - model_config_id: Annotated[StrictStr | None, Field(...)] - historical_data: Annotated[ - dict[str, Any] | None, - Field( - description="Original request and input manifest of a terminal run migrated from the legacy Memory format. Present only on such runs; never accepted as evidence." - ), - ] = None + evidence_id: StrictStr + kind: DreamEvidenceKind + digest: StrictStr + source: DreamSourceReference | None = None + artifact: ArtifactReference | None = None + role: DreamEvidenceRole + historical: StrictBool = False -class DreamRunPage(BaseModel): +class DreamRootEvidenceGroup(BaseModel): model_config = ConfigDict( extra="forbid", ) - runs: list[DreamRun] - next_cursor: StrictStr | None = None + group_id: StrictStr + sources: list[DreamSourceReference] + independence: DreamEvidenceIndependence = DreamEvidenceIndependence.UNKNOWN class ExtractionObservation(BaseModel): @@ -5821,6 +5812,61 @@ class CreateSubjectSourceResponse(BaseModel): sources: Annotated[list[SourceRecord], Field(max_length=2, min_length=2)] +class DreamInputManifest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + transform_version: StrictStr = "powercontext.dream.evidence.v1" + artifacts: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] + sources: Annotated[list[DreamSourceReference], Field(validate_default=True)] = [] + nodes: Annotated[list[DreamEvidenceNode], Field(validate_default=True)] = [] + edges: Annotated[list[DreamEvidenceEdge], Field(validate_default=True)] = [] + root_groups: Annotated[list[DreamRootEvidenceGroup], Field(validate_default=True)] = [] + projection_digest: StrictStr + projection_bytes: StrictInt + incomplete: StrictBool = False + + +class DreamRun(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + tag_target: TagDreamTarget | None = None + reused: StrictBool = False + scope_id: StrictStr + run_id: StrictStr + operation: DreamOperation + status: DreamStatus = DreamStatus.QUEUED + outcome: DreamOutcome | None = None + target: ArtifactReference | None = None + candidate: DreamCandidateRef | None = None + reason: StrictStr | None = None + error: StrictStr | None = None + accepted_at: AwareDatetime + started_at: AwareDatetime | None = None + completed_at: AwareDatetime | None = None + attempt_count: StrictInt = 0 + input_manifest: DreamInputManifest | None = None + usage: DreamUsage | None = None + budget: DreamBudget | None = None + prompt_version: StrictStr = "powercontext.dream.v1.4" + model_config_id: Annotated[StrictStr | None, Field(...)] + historical_data: Annotated[ + dict[str, Any] | None, + Field( + description="Original request and input manifest of a terminal run migrated from the legacy Memory format. Present only on such runs; never accepted as evidence." + ), + ] = None + + +class DreamRunPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + runs: list[DreamRun] + next_cursor: StrictStr | None = None + + class ExtractionStatus(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 9f8bd078a2..e81ac532ee 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -7806,6 +7806,7 @@ "properties": { "source_type": {"type": "string", "maxLength": 128, "minLength": 1}, "source_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "locator": {"$ref": "#/components/schemas/SourceLocator", "nullable": True}, }, "additionalProperties": False, "type": "object", @@ -14083,6 +14084,61 @@ "type": "object", "required": ["items", "next_cursor"], }, + "SourceSequenceLocator": { + "properties": { + "kind": {"type": "string", "enum": ["sequence"]}, + "sequence": {"type": "integer", "minimum": 0.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "sequence"], + "description": "Position of this exact Source in its scope journal; zero is distinct from absence.", + }, + "SourceLineRangeLocator": { + "properties": { + "kind": {"type": "string", "enum": ["line_range"]}, + "start_line": {"type": "integer", "minimum": 1.0}, + "end_line": {"type": "integer", "minimum": 1.0}, + "heading_path": { + "type": "string", + "maxLength": 1024, + "description": "Optional " + "adapter-supplied " + "heading " + "path, " + "bounded " + "in " + "Unicode " + "characters " + "rather " + "than " + "UTF-8 " + "bytes.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "start_line", "end_line"], + "description": "Inclusive one-based lines " + "in adapter-supplied text, " + "at most 200 lines. Never a " + "revision redirect.", + "x-powercontext-source-line-range": True, + }, + "SourceLocator": { + "oneOf": [ + {"$ref": "#/components/schemas/SourceSequenceLocator"}, + {"$ref": "#/components/schemas/SourceLineRangeLocator"}, + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "sequence": "#/components/schemas/SourceSequenceLocator", + "line_range": "#/components/schemas/SourceLineRangeLocator", + }, + }, + }, "SourceTypeReference": { "properties": { "source_type": { @@ -14105,6 +14161,7 @@ "interior " "spaces.", }, + "locator": {"$ref": "#/components/schemas/SourceLocator", "nullable": True}, }, "additionalProperties": False, "type": "object", @@ -14114,6 +14171,7 @@ "properties": { "name": {"type": "string", "description": "Stable Source type."}, "source_id": {"type": "string"}, + "locator": {"$ref": "#/components/schemas/SourceLocator", "nullable": True}, }, "additionalProperties": False, "type": "object", diff --git a/src/powercontext/limits.py b/src/powercontext/limits.py index 1897a293b4..347adbbfee 100644 --- a/src/powercontext/limits.py +++ b/src/powercontext/limits.py @@ -26,6 +26,8 @@ MAX_SOURCE_ID_LENGTH = 256 MAX_SOURCE_TYPE_LENGTH = 128 MAX_SOURCE_OBSERVATION_BYTES = 4 * 1024 * 1024 +MAX_SOURCE_LOCATOR_LINES = 200 +MAX_SOURCE_HEADING_PATH_LENGTH = 1_024 MAX_ARTIFACT_FAMILY_LENGTH = 128 MAX_ARTIFACT_ID_LENGTH = 128 MAX_ARTIFACT_REVISION_ACTOR_LENGTH = 2_048 diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 52d14106c7..d9189f100e 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -405,6 +405,7 @@ InvalidConnectorRunError, InvalidSourceDefinitionError, InvalidSourceObservationError, + InvalidSourceReferenceError, PowerContextError, RevisionConflictError, SourceConflictError, @@ -6751,6 +6752,13 @@ def _invalid_request_details(error: Exception) -> dict[str, Any] | None: def _map_source_ingestion_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, InvalidSourceReferenceError): + return ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "invalid_request", + "The Source reference is invalid.", + {"field": error.field}, + ) if isinstance(error, SourceConflictError): return status.HTTP_409_CONFLICT, "source_conflict", "The Source identity has different content.", None if isinstance(error, InvalidConnectorRunError): diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index fdb15890b3..4a3bbbf9f3 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -198,6 +198,7 @@ CommittedHandoff, ConnectorCheckpointState, CreateWorkContractRequest, + DreamSourceReference, EntryChange, EntryChangeOperation, ExperienceArtifact, @@ -526,7 +527,7 @@ def _runtime_task_check(value: TaskCheck) -> RuntimeTaskCheck: def capture_response(value: SourceReceipt) -> CaptureContentSourceResponse: return CaptureContentSourceResponse( status=CaptureStatus.ACCEPTED, - source=SourceReference(name=value.source_ref.source_type, source_id=value.source_ref.source_id), + source=source_reference(value.source_ref), position=value.sequence, ) @@ -703,9 +704,7 @@ def revise_candidate_request(value: ReviseCandidateRequest) -> RuntimeReviseCand sources = ( (None if "source_refs" not in value.model_fields_set else sources) if value.sources is None - else tuple( - SourceRef(source_type=source.source_type, source_id=source.source_id) for source in value.sources - ) + else tuple(runtime_source_type_reference(source) for source in value.sources) ) artifacts = ( (None if "artifact_refs" not in value.model_fields_set else artifacts) @@ -1263,19 +1262,27 @@ def runtime_artifact_reference(value: ArtifactReference) -> ArtifactRef: def source_reference(value: SourceRef) -> SourceReference: - return SourceReference(name=value.source_type, source_id=value.source_id) + return SourceReference.model_validate({ + "name": value.source_type, + "source_id": value.source_id, + "locator": None if value.locator is None else value.locator.model_dump(mode="json"), + }) def runtime_source_reference(value: SourceReference) -> SourceRef: - return SourceRef(source_type=value.name, source_id=value.source_id) + return SourceRef.model_validate({ + "source_type": value.name, + "source_id": value.source_id, + "locator": None if value.locator is None else value.locator.model_dump(mode="json"), + }) def source_type_reference(value: SourceRef) -> SourceTypeReference: - return SourceTypeReference(source_type=value.source_type, source_id=value.source_id) + return SourceTypeReference.model_validate(value.model_dump(mode="json")) -def runtime_source_type_reference(value: SourceTypeReference) -> SourceRef: - return SourceRef(source_type=value.source_type, source_id=value.source_id) +def runtime_source_type_reference(value: SourceTypeReference | DreamSourceReference) -> SourceRef: + return SourceRef.model_validate(value.model_dump(mode="json")) def runtime_handoff_citation(value: TransportHandoffCitation) -> RuntimeHandoffCitation: diff --git a/src/powercontext/sources/__init__.py b/src/powercontext/sources/__init__.py index 5469244670..998bd78bc2 100644 --- a/src/powercontext/sources/__init__.py +++ b/src/powercontext/sources/__init__.py @@ -31,7 +31,15 @@ SourceProjection, ) from powercontext.sources.evidence import MemoryEvidenceDeclaration, SourceEvidence -from powercontext.sources.models import Source, SourceMaterialization, SourceProjectionKey, SourceRef +from powercontext.sources.models import ( + Source, + SourceLineRangeLocator, + SourceLocator, + SourceMaterialization, + SourceProjectionKey, + SourceRef, + SourceSequenceLocator, +) from powercontext.sources.observations import ( SourceDefinitionManifest, SourceObservation, @@ -40,7 +48,12 @@ manifest_for_definition, project_source_for_transport, ) -from powercontext.sources.projections import TEXT_EVIDENCE_PROJECTION_KEY, TextEvidence +from powercontext.sources.projections import ( + TEXT_EVIDENCE_PROJECTION_KEY, + LocatedTextEvidence, + TextEvidence, + TextEvidenceV1, +) from powercontext.sources.protocols import SourceCatalogBackend, SourceStore __all__ = [ @@ -54,6 +67,7 @@ "ConnectorRunSession", "ConnectorRunStatus", "ConnectorSubmissionStatus", + "LocatedTextEvidence", "MemoryEvidenceDeclaration", "Source", "SourceAdapter", @@ -63,6 +77,8 @@ "SourceDefinitionManifest", "SourceDefinitionRegistry", "SourceEvidence", + "SourceLineRangeLocator", + "SourceLocator", "SourceMaterialization", "SourceObservation", "SourceProjection", @@ -70,8 +86,10 @@ "SourceProjectionManifest", "SourceProjectionValue", "SourceRef", + "SourceSequenceLocator", "SourceStore", "TextEvidence", + "TextEvidenceV1", "manifest_for_definition", "project_source_for_transport", ] diff --git a/src/powercontext/sources/models.py b/src/powercontext/sources/models.py index 0f1a4a26fe..8d5fc7d05a 100644 --- a/src/powercontext/sources/models.py +++ b/src/powercontext/sources/models.py @@ -15,11 +15,18 @@ from __future__ import annotations from enum import StrEnum +from typing import Annotated, Literal, TypeAlias -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator +from pydantic_core import PydanticCustomError from powercontext.errors import InvalidSourceReferenceError -from powercontext.limits import MAX_SOURCE_ID_LENGTH, MAX_SOURCE_TYPE_LENGTH +from powercontext.limits import ( + MAX_SOURCE_HEADING_PATH_LENGTH, + MAX_SOURCE_ID_LENGTH, + MAX_SOURCE_LOCATOR_LINES, + MAX_SOURCE_TYPE_LENGTH, +) class SourceMaterialization(StrEnum): @@ -29,11 +36,46 @@ class SourceMaterialization(StrEnum): REFERENCED = "referenced" +class SourceSequenceLocator(BaseModel): + """A Source's zero-based-or-greater position in its scope's journal.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["sequence"] = "sequence" + sequence: StrictInt = Field(ge=0) + + +class SourceLineRangeLocator(BaseModel): + """Inclusive, one-based lines in the exact text supplied by the Source adapter.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["line_range"] = "line_range" + start_line: StrictInt = Field(ge=1) + end_line: StrictInt = Field(ge=1) + heading_path: str | None = Field( + default=None, max_length=MAX_SOURCE_HEADING_PATH_LENGTH, exclude_if=lambda value: value is None + ) + + @model_validator(mode="after") + def validate_span(self) -> SourceLineRangeLocator: + if self.end_line < self.start_line or self.end_line - self.start_line + 1 > MAX_SOURCE_LOCATOR_LINES: + raise PydanticCustomError( + "source_locator_range", + f"Source line range must contain 1 to {MAX_SOURCE_LOCATOR_LINES} inclusive lines", + ) + return self + + +SourceLocator: TypeAlias = Annotated[SourceSequenceLocator | SourceLineRangeLocator, Field(discriminator="kind")] + + class SourceRef(BaseModel): """A stable reference to one Source in the current catalog view.""" source_type: str source_id: str + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) @field_validator("source_type", "source_id") @classmethod diff --git a/src/powercontext/sources/projections.py b/src/powercontext/sources/projections.py index 3864a75343..e43e188302 100644 --- a/src/powercontext/sources/projections.py +++ b/src/powercontext/sources/projections.py @@ -14,9 +14,9 @@ """Standard named projection schemas understood without Source plugin code.""" -from pydantic import BaseModel, Field, JsonValue +from pydantic import BaseModel, ConfigDict, Field, JsonValue -from powercontext.sources.models import SourceProjectionKey +from powercontext.sources.models import SourceLocator, SourceProjectionKey TEXT_EVIDENCE_PROJECTION_KEY = SourceProjectionKey(name="powercontext.text-evidence", version="1") @@ -24,10 +24,23 @@ class TextEvidence(BaseModel): """Canonical JSON shape consumed as textual Artifact evidence.""" + # Definitions already registered with this shape keep their immutable + # manifest identity when consumers gain support for richer text evidence. + model_config = ConfigDict(title="TextEvidence") + source_type: str source_id: str content: str metadata: dict[str, JsonValue] = Field(default_factory=dict) -__all__ = ["TEXT_EVIDENCE_PROJECTION_KEY", "TextEvidence"] +TextEvidenceV1 = TextEvidence + + +class LocatedTextEvidence(TextEvidence): + """Canonical JSON shape consumed as textual Artifact evidence.""" + + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) + + +__all__ = ["TEXT_EVIDENCE_PROJECTION_KEY", "LocatedTextEvidence", "TextEvidence", "TextEvidenceV1"] diff --git a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py index 518a0cbcbf..17dbcb0736 100644 --- a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py +++ b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py @@ -26,6 +26,7 @@ import json from typing import Any, cast +import pytest from pydantic import JsonValue from powercontext.builtin.artifacts.atomic_memory.extraction import project_atomic_memory_evidence @@ -34,11 +35,13 @@ from powercontext.builtin.sources.content import CONTENT_SOURCE_NAME, ContentCapture, ContentSource from powercontext.sources import ( TEXT_EVIDENCE_PROJECTION_KEY, + LocatedTextEvidence, SourceMaterialization, SourceObservation, SourceProjectionKey, SourceProjectionValue, SourceRef, + SourceSequenceLocator, TextEvidence, ) @@ -296,3 +299,50 @@ def test_another_projection_keeps_its_keys_but_not_a_repeated_body() -> None: "value": {"database": "OceanBase", "shards": 12}, } ] + + +def test_extraction_cites_the_recorded_journal_position() -> None: + evidence = asyncio.run(_evidence(_native_source())) + assert evidence.source_ref.model_dump(mode="json")["locator"] == {"kind": "sequence", "sequence": 1} + + +def test_extraction_preserves_adapter_reported_text_position() -> None: + projection = _text_projection().model_dump(mode="json") + locator = {"kind": "line_range", "start_line": 40, "end_line": 55, "heading_path": "部署 / 决策"} + projection["value"]["locator"] = locator + evidence = asyncio.run(_evidence(_observation((SourceProjectionValue.model_validate(projection),)))) + assert evidence.source_ref.model_dump(mode="json")["locator"] == locator + + +@pytest.mark.parametrize("representation", ["native", "observation"]) +@pytest.mark.parametrize("sequence", [0, 1, 42]) +def test_extraction_uses_stored_journal_position_over_adapter_sequence(representation, sequence) -> None: + async def scenario() -> None: + text = LocatedTextEvidence( + source_type="tool-result", + source_id="observation-1", + content=_BODY, + locator=SourceSequenceLocator(sequence=sequence), + ) + observation = _observation(( + SourceProjectionValue(key=TEXT_EVIDENCE_PROJECTION_KEY, value=text.model_dump(mode="json")), + )) + value = observation if representation == "observation" else _native_source() + stored = StoredSource( + ref=SourceRef(source_type="tool-result", source_id=value.name), + value=value, + journal_position=1, + ) + + class AdapterRead: + async def read_value(self, source, /): + return text + + evidence = await project_atomic_memory_evidence( + stored, cast("SourceRepository", AdapterRead()), evidence_id="source:1" + ) + assert evidence.source_ref == stored.ref.model_copy(update={"locator": SourceSequenceLocator(sequence=1)}) + stored.validate_locator(evidence.source_ref) + assert evidence.content == text.model_dump(mode="json") + + asyncio.run(scenario()) diff --git a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence_projection.py b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence_projection.py index 01a49c7fcf..0e74766318 100644 --- a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence_projection.py +++ b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence_projection.py @@ -35,6 +35,7 @@ SourceProjectionKey, SourceProjectionValue, SourceRef, + SourceSequenceLocator, TextEvidence, project_source_for_transport, ) @@ -119,7 +120,9 @@ async def scenario() -> None: assert evidence.evidence_id == "source:1" assert evidence.journal_position == 1 - assert evidence.source_ref == SourceRef(source_type="content", source_id="turn-1") + assert evidence.source_ref == SourceRef( + source_type="content", source_id="turn-1", locator=SourceSequenceLocator(sequence=1) + ) assert isinstance(evidence.content, dict) assert evidence.content["content"] == BODY @@ -149,7 +152,9 @@ async def scenario() -> None: assert evidence.evidence_id == "source:1" assert evidence.journal_position == 1 - assert evidence.source_ref == SourceRef(source_type="content", source_id="turn-1") + assert evidence.source_ref == SourceRef( + source_type="content", source_id="turn-1", locator=SourceSequenceLocator(sequence=1) + ) assert isinstance(evidence.content, dict) assert evidence.content["content"] == BODY diff --git a/tests/builtin/persistence/test_artifacts.py b/tests/builtin/persistence/test_artifacts.py index 596c970be1..6ab1f9e9cd 100644 --- a/tests/builtin/persistence/test_artifacts.py +++ b/tests/builtin/persistence/test_artifacts.py @@ -22,11 +22,12 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.topic_memory import TopicMemory, TopicMemoryContent, TopicMemoryDraft +from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, ) from powercontext.builtin.runtime.relational import RelationalContexts -from powercontext.errors import RevisionConflictError +from powercontext.errors import InvalidSourceReferenceError, RevisionConflictError from powercontext.sources import SourceDefinitionRegistry, SourceMaterialization, SourceRef from tests.builtin.persistence.contract import ( SOURCE_ADAPTERS, @@ -132,6 +133,69 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_source_citation_positions_survive_separate_transactions() -> None: + async def scenario() -> None: + async with repository_profile() as (profile, repositories): + async with profile.database.transaction() as connection: + stored = await repositories.sources.add( + connection, + "scope-a", + NoteSource(name="positioned", materialization=SourceMaterialization.CAPTURED, body="证据"), + ) + refs = tuple( + SourceRef.model_validate({ + **stored.ref.model_dump(), + "locator": locator, + }) + for locator in ( + {"kind": "sequence", "sequence": stored.journal_position}, + {"kind": "line_range", "start_line": 40, "end_line": 55, "heading_path": "设计"}, + {"kind": "line_range", "start_line": 60, "end_line": 65}, + ) + ) + await repositories.artifacts.create( + connection, + "scope-a", + "positioned-report", + ReportDraft(content=ReportContent(status="green"), sources=(*refs, stored.ref)), + ) + async with profile.database.transaction() as connection: + loaded = await repositories.artifacts.revisions(connection, "scope-a", "report", "positioned-report") + assert loaded[0].lineage.sources == (*refs, stored.ref) + assert loaded[0].lineage.sources[-1].model_dump() == stored.ref.model_dump() + + asyncio.run(scenario()) + + +def test_source_locator_cannot_redirect_to_another_journal_entry() -> None: + async def scenario() -> None: + async with repository_profile() as (profile, repositories), profile.database.transaction() as connection: + stored = await repositories.sources.add( + connection, + "scope-a", + NoteSource(name="first", materialization=SourceMaterialization.CAPTURED, body="first fact"), + ) + other = await repositories.sources.add( + connection, + "scope-a", + NoteSource(name="second", materialization=SourceMaterialization.CAPTURED, body="unrelated fact"), + ) + wrong = SourceRef.model_validate({ + **stored.ref.model_dump(), + "locator": {"kind": "sequence", "sequence": other.journal_position}, + }) + with pytest.raises(InvalidSourceReferenceError, match=r"locator\.sequence"): + await ArtifactRepository((Report,), sources=repositories.sources).create( + connection, + "scope-a", + "wrong-position", + ReportDraft(content=ReportContent(status="green"), sources=(wrong,)), + ) + assert await repositories.artifacts.revisions(connection, "scope-a", "report", "wrong-position") == () + + asyncio.run(scenario()) + + def test_relational_contexts_persist_topic_memory_revisions_and_exact_lineage() -> None: async def scenario() -> None: async with repository_profile() as (profile, repositories): diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py index 534a838205..7b05f1ddaf 100644 --- a/tests/builtin/persistence/test_atomic_description_vector_layout.py +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -43,7 +43,7 @@ def test_managed_sqlite_vector_schema_includes_registered_nullable_description(t 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_5_0_003" + assert result.revision == "v1_5_0_004" 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) @@ -156,11 +156,11 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm upgraded = SQLiteMigrationRunner(database, current, configuration=configuration) plan = upgraded.plan() assert plan.source_revision == "v1_4_1_004" - assert plan.target_revision == "v1_5_0_003" - assert plan.revisions == ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003") + assert plan.target_revision == "v1_5_0_004" + assert plan.revisions == ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") assert plan.domain_steps == ("artifact_storage", "atomic_memory_evidence", "atomic_memory_evidence_projection") assert upgraded.evidence_path.read_bytes() == original_evidence_bytes - assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_003" + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_004" 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(): diff --git a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py index 6e677d1d05..0d9f2bc9a6 100644 --- a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py +++ b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py @@ -159,8 +159,15 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, plan = upgraded.plan() assert plan.state == "migration_required" assert plan.source_revision == "v1_4_1_004" - assert plan.target_revision == PROJECTION_REVISION - assert plan.revisions == ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", EVIDENCE_REVISION, PROJECTION_REVISION) + assert plan.target_revision == current.head + assert plan.revisions == ( + "v1_4_1_005", + "v1_4_1_006", + "v1_5_0_001", + EVIDENCE_REVISION, + PROJECTION_REVISION, + "v1_5_0_004", + ) assert plan.domain_steps == ( "artifact_storage", "atomic_memory_evidence", @@ -169,7 +176,7 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, assert upgraded.evidence_path.read_bytes() == old_evidence_bytes result = upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) assert result.changed - assert result.revision == PROJECTION_REVISION + assert result.revision == current.head assert result.backup_state == "completed" # Reconstruct both bundle and runner: verification must use retained on-disk @@ -186,11 +193,12 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, "v1_5_0_001", EVIDENCE_REVISION, PROJECTION_REVISION, + "v1_5_0_004", } ready = reopened.plan() assert ready.state == "ready" assert ready.revisions == () - assert reopened.verify().revision == PROJECTION_REVISION + assert reopened.verify().revision == current.head assert not apply(reopened).changed assert reopened.evidence_path.read_bytes() == completed_evidence_bytes with closing(sqlite3.connect(database)) as connection: @@ -225,25 +233,10 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, @pytest.mark.parametrize("vectors", [False, True], ids=["lexical", "vectors"]) def test_evidence_projection_upgrade_preserves_registered_evidence_revision(tmp_path, vectors): embedding = EmbeddingProfile(profile_id="evidence-upgrade", model="fixture", dimension=3) if vectors else None + from tests.builtin.persistence.test_portable_storage_migration import previous_bundle + current = server_bundle(embedding_profile=embedding) - directory = tmp_path / "evidence-bundle" - 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")) - removed = manifest["revision_resources"].pop(PROJECTION_REVISION) - removed.append("atomic_memory_evidence_projection.py") - revision = current.script.get_revision(PROJECTION_REVISION) - removed.append(Path(revision.path).relative_to(current.snapshot_directory).as_posix()) - removed.append(f"mysql/versions/{PROJECTION_REVISION}.py") - for resource in removed: - (directory / resource).unlink() - del manifest["sqlite_fingerprints"][PROJECTION_REVISION] - del manifest["domain_readiness"][PROJECTION_REVISION] - manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] != PROJECTION_REVISION] - manifest["mysql_state_layouts"] = [name for name in manifest["mysql_state_layouts"] if name not in removed] - manifest["affected_objects"].remove("pc_atomic_memory_evidence") - manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - previous = MigrationBundle(directory, embedding_profile=embedding) + previous = previous_bundle(tmp_path / "evidence-bundle", embedding, through=EVIDENCE_REVISION) assert previous.head == EVIDENCE_REVISION assert {revision: current.checksums[revision] for revision in previous.revisions} == previous.checksums @@ -254,12 +247,10 @@ def test_evidence_projection_upgrade_preserves_registered_evidence_revision(tmp_ before = old.evidence_path.read_bytes() upgraded = SQLiteMigrationRunner(database, current, configuration=config) plan = upgraded.plan() - assert plan.revisions == (PROJECTION_REVISION,) + assert plan.revisions == (PROJECTION_REVISION, "v1_5_0_004") assert plan.domain_steps == ("atomic_memory_evidence_projection",) assert old.evidence_path.read_bytes() == before - assert ( - upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == PROJECTION_REVISION - ) + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == current.head assert upgraded.verify().state == "ready" with closing(sqlite3.connect(database)) as connection: assert connection.execute("SELECT COUNT(*) FROM pc_atomic_memory_evidence").fetchone() == (0,) diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index 6a9abe288a..74af6392ae 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,7 +73,7 @@ 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_5_0_003" + assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_004" assert plan.revisions == ( "s1400", "s1401", @@ -84,6 +84,7 @@ def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", ) assert dump(source.database) == before result = apply(source) diff --git a/tests/builtin/persistence/test_mysql_initialization_preflight.py b/tests/builtin/persistence/test_mysql_initialization_preflight.py index 085a49aef0..87abc9190a 100644 --- a/tests/builtin/persistence/test_mysql_initialization_preflight.py +++ b/tests/builtin/persistence/test_mysql_initialization_preflight.py @@ -253,7 +253,7 @@ def test_evidence_recovery_resumes_before_final_schema_marker(tmp_path, monkeypa before = database.read_bytes(), journal.path.read_bytes() plan = runner.plan() assert plan.state == "recovery_required" and plan.source_revision == predecessor - assert plan.revisions == ("v1_5_0_002", "v1_5_0_003") + assert plan.revisions == ("v1_5_0_002", "v1_5_0_003", "v1_5_0_004") assert (database.read_bytes(), journal.path.read_bytes()) == before assert connection.exec_driver_sql("SELECT schema_version FROM pc_atomic_memory_evidence_schema").all() == [] @@ -278,7 +278,7 @@ def execute_revision(connection, _config, _source, target, guard): monkeypatch.setattr(runner, "_upgrade", execute_revision) result = runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) - assert result.state == "ready" and result.revision == "v1_5_0_003" + assert result.state == "ready" and result.revision == runner.bundle.head assert result.backup_state == "not_required" assert runner.verify().state == "ready" assert connection.exec_driver_sql("SELECT schema_version FROM pc_atomic_memory_evidence_schema").all() == [ @@ -288,7 +288,7 @@ def execute_revision(connection, _config, _source, target, guard): assert completed is not None and completed.state == "complete" assert completed.maintenance_window_id == "interrupted-evidence-ddl" assert completed.source_revision is None and completed.backup_state == "not_required" - assert completed.last_revision == "v1_5_0_003" and completed.pending_revision is None + assert completed.last_revision == runner.bundle.head and completed.pending_revision is None assert completed.release_checksums == runner.checksums assert not runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True).changed diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index b5e27d1ec1..2894087ff8 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -706,7 +706,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_5_0_003" + assert result.readiness_scope == "server" and result.revision == "v1_5_0_004" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -837,7 +837,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_5_0_003", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_004", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -847,7 +847,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_5_0_003", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_004", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -914,7 +914,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_5_0_003", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_004", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -982,7 +982,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_5_0_003", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_5_0_004", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -1146,7 +1146,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_5_0_003" + assert original.source_revision == "s1300" and original.target_revision == "v1_5_0_004" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1168,7 +1168,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_5_0_003" + assert result.readiness_scope == "server" and result.revision == "v1_5_0_004" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1248,7 +1248,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_5_0_003" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_004" 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_mysql_vector_admission.py b/tests/builtin/persistence/test_mysql_vector_admission.py index 330dd3ea2d..e56c83545a 100644 --- a/tests/builtin/persistence/test_mysql_vector_admission.py +++ b/tests/builtin/persistence/test_mysql_vector_admission.py @@ -73,6 +73,8 @@ def run(operation, *, writable=False, allow_missing_database=False): # This fixture always has a database, so missing-database admission has no effect. del allow_missing_database assert not writable, "Admission must fail before requesting a write connection" + # This fixture always supplies an existing database, so the policy for + # missing databases does not change the connection passed to admission. engine = create_engine(f"sqlite:///{database}") try: with engine.connect() as connection: diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index b083790f77..d016bed258 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -126,8 +126,9 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", ) - assert apply(upgraded).revision == "v1_5_0_003" + assert apply(upgraded).revision == "v1_5_0_004" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) @@ -186,11 +187,11 @@ def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, v bundle = server_bundle(embedding_profile=embedding) upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) assert upgraded.plan().revisions == ( - ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003") + ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") if include_owner - else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003") + else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") ) - assert apply(upgraded).revision == "v1_5_0_003" + assert apply(upgraded).revision == "v1_5_0_004" definition = b'{"configuration":{"id":"tickets","entry":"tickets"}}' with closing(sqlite3.connect(database)) as connection, connection: assert connection.execute("SELECT title FROM pc_scopes").fetchall() == [("preserved",)] diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index ae499d8836..fd95f653eb 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -16,8 +16,10 @@ import json import sqlite3 +import struct from contextlib import closing from datetime import UTC, datetime +from hashlib import sha256 import pytest import sqlite_vec @@ -26,11 +28,21 @@ from sqlalchemy import create_engine from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.atomic_memory.models import ( + AtomicMemoryContent, +) from powercontext.builtin.artifacts.experience import ExperienceContent from powercontext.builtin.artifacts.memory import EmbeddingProfile +from powercontext.builtin.artifacts.memory.canonical import canonical_json +from powercontext.builtin.artifacts.search import analyze_text from powercontext.builtin.code import CodeConfig from powercontext.builtin.dream.models import CreateDreamRunRequest, DreamRecord, DreamRun from powercontext.builtin.inference.models import EmbeddingResult +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.candidates import CandidateRepository from powercontext.builtin.persistence.dream import DreamRepository from powercontext.builtin.persistence.migrations.deployment import deployment_runner, server_bundle @@ -144,6 +156,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", ) @@ -307,3 +320,67 @@ def test_release140_bridge_preserves_dependent_views(tmp_path): == definitions ) assert not _apply(runner).changed + + +@pytest.mark.parametrize("release", ["1.3.0", "1.4.0"]) +@pytest.mark.parametrize("vectors", [False, True]) +def test_populated_release_atomic_memory_survives_upgrade_before_locator_table_exists(tmp_path, release, vectors): + settings, embedding, runner = _source(tmp_path, release=release, vectors=vectors) + content = AtomicMemoryContent(kind="fact", text="Historical Atomic Memory remains readable.") + + def connect(): + connection = sqlite3.connect(runner.database) + if vectors: + connection.enable_load_extension(True) + try: + sqlite_vec.load(connection) + finally: + connection.enable_load_extension(False) + return connection + + engine = create_engine("sqlite://", creator=connect) + try: + with engine.begin() as connection: + connection.exec_driver_sql( + "INSERT INTO pc_artifacts (scope_id,family,artifact_id,revision,content) " + "VALUES ('scope','atomic-memory','historical-memory',1,?)", + (content.model_dump_json(by_alias=True).encode(),), + ) + connection.exec_driver_sql( + "INSERT INTO pc_artifact_heads (scope_id,family,artifact_id,revision) " + "VALUES ('scope','atomic-memory','historical-memory',1)" + ) + # Seed the released layout directly: today's publisher also writes + # description, which is absent until the explicit 005 migration. + connection.exec_driver_sql( + "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','historical-memory',1,0,?,'powercontext.atomic-memory.current.v1',?,?,?,?,?,?,?)", + ( + sha256(canonical_json(content.model_dump(mode="json", by_alias=True))).hexdigest(), + content.kind, + content.text, + analyze_text(atomic_memory_embedding_input(content.kind, content.text)), + "[]", + None if embedding is None else struct.pack("<3f", 1.0, 0.0, 0.0), + None if embedding is None else atomic_memory_profile_fingerprint(embedding), + None if embedding is None else atomic_memory_embedding_input_hash(content.kind, content.text), + ), + ) + finally: + engine.dispose() + + assert _apply(runner).revision == "v1_5_0_004" + assert runner.verify().state == "ready" + assert not _apply(runner).changed + app = create_server_app( + settings=settings, + embedding_model=ControlledEmbedding(embedding) if embedding is not None else None, + scheduler_path=tmp_path / "scheduler", + ) + with TestClient(app) as client: + response = client.get("/v1/scopes/scope/artifacts/atomic-memory/historical-memory/revisions/1") + assert response.status_code == 200 + assert response.json()["content"]["text"] == content.text + assert response.json()["sources"] == [] diff --git a/tests/builtin/portability/test_bundle.py b/tests/builtin/portability/test_bundle.py index 0b8307a197..a8cad4497a 100644 --- a/tests/builtin/portability/test_bundle.py +++ b/tests/builtin/portability/test_bundle.py @@ -38,7 +38,9 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, + ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_PUBLICATIONS_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_TAGS_TABLE, ARTIFACTS_TABLE, BUILTIN_TABLES, @@ -570,10 +572,30 @@ async def scenario() -> None: searchable_text="not portable", ) ) + edge = { + "scope_id": "project:one", + "family": "handoff", + "artifact_id": "handoff", + "revision": 1, + "ordinal": 0, + } + await connection.execute( + insert(ARTIFACT_LINEAGE_SOURCES_TABLE).values( + **edge, + source_type="content", + source_id="source-1", + ) + ) + await connection.execute( + insert(ARTIFACT_SOURCE_LOCATORS_TABLE).values( + **edge, + locator=b'{"kind":"line_range","start_line":1,"end_line":1}', + ) + ) receipt = await PortableBundleService(source.database).export( ["project:one"], archive, authorize=_authorize_export ) - assert receipt.record_count == 5 + assert receipt.record_count == 7 async with SQLiteProfile.open( SQLiteConfig(url=f"sqlite+aiosqlite:///{target_path}"), tables=BUILTIN_TABLES @@ -582,11 +604,14 @@ async def scenario() -> None: inspection = await service.validate(archive, supported_source_types=("content",)) assert inspection.scopes == ("project:one",) restored = await service.restore(archive, supported_source_types=("content",)) - assert restored.inserted == 5 + assert restored.inserted == 7 repeated = await service.restore(archive, supported_source_types=("content",)) - assert repeated.already_present == 5 + assert repeated.already_present == 7 async with target.database.transaction() as connection: assert await connection.scalar(select(ARTIFACT_HEADS_TABLE.c.searchable_text)) is None + locator = await connection.scalar(select(ARTIFACT_SOURCE_LOCATORS_TABLE.c.locator)) + assert isinstance(locator, bytes) + assert json.loads(locator) == {"kind": "line_range", "start_line": 1, "end_line": 1} asyncio.run(scenario()) diff --git a/tests/builtin/review/test_service.py b/tests/builtin/review/test_service.py index 862660461c..4e5e5a9180 100644 --- a/tests/builtin/review/test_service.py +++ b/tests/builtin/review/test_service.py @@ -1021,3 +1021,34 @@ async def fail_status_update(*_args, **_kwargs): ) asyncio.run(scenario()) + + +def test_candidate_preserves_distinct_positions_from_one_source() -> None: + async def scenario() -> None: + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + scope = await _create_scope(runtime) + captured = await runtime.sources.for_scope(scope).capture( + CaptureSource(source_id="positioned", content="Two independent observations.", metadata={}) + ) + refs = tuple( + SourceRef.model_validate({ + **captured.source_ref.model_dump(), + "locator": {"kind": "line_range", "start_line": line, "end_line": line}, + }) + for line in (1, 2) + ) + candidate = await runtime.experience.for_scope(scope).propose( + ProposeExperienceRequest(proposal=_proposal(), sources=refs) + ) + loaded = await runtime.review.for_scope(scope).get(GetCandidateRequest(candidate_id=candidate.candidate_id)) + assert loaded.sources == refs + approved = await runtime.review.for_scope(scope).approve( + ApproveCandidateRequest(candidate_id=candidate.candidate_id, expected_version=candidate.version) + ) + assert approved.result_artifact is not None + result = await runtime.experience.for_scope(scope).get( + GetExperienceRequest(artifact=approved.result_artifact) + ) + assert result.lineage.sources == refs + + asyncio.run(scenario()) diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index 483ae3d768..6e245c56b4 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -84,7 +84,7 @@ from powercontext.builtin.scope import ScopeDraft from powercontext.builtin.sources import ContentSource from powercontext.server.app import ServerApplication -from powercontext.sources import Source, SourceRef +from powercontext.sources import Source, SourceRef, SourceSequenceLocator from tests.e2e.dream_support import atomic_memory_pipeline, process_pending from tests.e2e.dream_support import open_dream_runtime as open_builtin_runtime @@ -1234,7 +1234,11 @@ async def seed(runtime: BuiltinRuntime): )) 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") - return scope.scope_id, captured.source_ref, citation + return ( + scope.scope_id, + captured.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=captured.sequence)}), + citation, + ) async def forget_memory(runtime: BuiltinRuntime, scope_id: str, ref): @@ -2533,7 +2537,7 @@ async def scenario() -> None: result = await runtime.review.for_scope(scope).get( GetCandidateRequest(candidate_id=run.candidate.candidate_id) ) - assert result.sources == (root,) + assert result.sources == (root.model_copy(update={"locator": SourceSequenceLocator(sequence=1)}),) assert {ref.model_dump_json() for ref in result.artifacts} == { ref.model_dump_json() for ref in (approved.result_artifact, *citations) } @@ -2807,7 +2811,15 @@ async def scenario() -> None: target = approved.result_artifact assert target is not None and target.revision == revision stored = await runtime.experience.for_scope(scope).get(GetExperienceRequest(artifact=target)) - assert stored.lineage.sources == (source.source_ref,) + assert stored.lineage.sources == ( + ( + source.source_ref.model_copy( + update={"locator": SourceSequenceLocator(sequence=source.sequence)} + ) + if with_memory and revision == 1 + else source.source_ref + ), + ) assert stored.lineage.artifacts == (*refs, *(citations if revision == 1 else ())) with pytest.raises(EvidenceResolutionError, match="evidence_limit_exceeded"): await runtime.dream.for_scope(scope).create( @@ -3257,3 +3269,35 @@ async def delayed_request(): assert wrong_version.status_code == 409, wrong_version.text asyncio.run(scenario()) + + +def test_source_positions_share_one_root_without_losing_their_locations(database: DatabaseConfig) -> None: + async def scenario() -> None: + generator = Generator() + async with open_builtin_runtime( + config(database), candidate_pipeline=atomic_memory_pipeline(MemoryPipeline()), dream_generator=generator + ) as runtime: + scope, root, citation = await seed(runtime) + refs = tuple( + SourceRef.model_validate({ + **root.model_dump(), + "locator": {"kind": "line_range", "start_line": line, "end_line": line}, + }) + for line in (1, 2) + ) + accepted = await runtime.dream.for_scope(scope).create( + CreateDreamRunRequest( + operation="refine_experience", + sources=refs, + artifacts=(citation,), + idempotency_key="positioned-root", + ) + ) + await process_pending(runtime) + run = await runtime.dream.for_scope(scope).get(GetDreamRunRequest(run_id=accepted.run_id)) + assert run.input_manifest is not None + assert len(run.input_manifest.root_groups) == 1 + assert all(ref in run.input_manifest.root_groups[0].sources for ref in refs) + assert run.input_manifest.root_groups[0].independence == "unknown" + + asyncio.run(scenario()) diff --git a/tests/e2e/test_atomic_memory_description_upgrade.py b/tests/e2e/test_atomic_memory_description_upgrade.py index bebf24b2b8..48db983ef8 100644 --- a/tests/e2e/test_atomic_memory_description_upgrade.py +++ b/tests/e2e/test_atomic_memory_description_upgrade.py @@ -85,9 +85,9 @@ def test_nonempty_released_memory_upgrades_without_rewriting_history(tmp_path, r ) result = _apply(runner) - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_003" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_004" verified = runner.verify() - assert verified.state == "ready" and verified.readiness_scope == "server" and verified.revision == "v1_5_0_003" + assert verified.state == "ready" and verified.readiness_scope == "server" and verified.revision == "v1_5_0_004" assert not _apply(runner).changed with closing(sqlite3.connect(runner.database)) as connection: assert ( diff --git a/tests/e2e/test_atomic_memory_flush_concurrency.py b/tests/e2e/test_atomic_memory_flush_concurrency.py index c6cdb453ec..f235c43f2d 100644 --- a/tests/e2e/test_atomic_memory_flush_concurrency.py +++ b/tests/e2e/test_atomic_memory_flush_concurrency.py @@ -45,6 +45,7 @@ open_builtin_runtime, ) from powercontext.builtin.scope import ScopeDraft +from powercontext.sources import SourceSequenceLocator from tests.e2e.dream_support import atomic_memory_pipeline, memory_source_text @@ -129,7 +130,9 @@ async def scenario() -> None: assert first.current_cursor == first.high_watermark == original.sequence (record,) = (await memory.list()).items assert record.artifact.content.text == "First fact." - assert record.artifact.lineage.sources == (original.source_ref,) + assert record.artifact.lineage.sources == ( + original.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=original.sequence)}), + ) following = await memory.flush(limit=1) assert following.previous_cursor == original.sequence assert following.current_cursor == later.sequence @@ -170,7 +173,9 @@ async def scenario() -> None: assert second_result.previous_cursor == second_result.current_cursor == source.sequence (record,) = (await memory.list()).items assert record.ref.revision == 1 - assert record.artifact.lineage.sources == (source.source_ref,) + assert record.artifact.lineage.sources == ( + source.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=source.sequence)}), + ) asyncio.run(scenario()) @@ -213,7 +218,9 @@ async def scenario() -> None: assert (await other_memory.cursor()).sequence == source.sequence (record,) = (await memory.list()).items assert record.ref.revision == 1 - assert record.artifact.lineage.sources == (source.source_ref,) + assert record.artifact.lineage.sources == ( + source.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=source.sequence)}), + ) asyncio.run(scenario()) diff --git a/tests/e2e/test_atomic_memory_generation_merge.py b/tests/e2e/test_atomic_memory_generation_merge.py index 9be4014fa5..72686fd493 100644 --- a/tests/e2e/test_atomic_memory_generation_merge.py +++ b/tests/e2e/test_atomic_memory_generation_merge.py @@ -40,7 +40,7 @@ from powercontext.builtin.runtime import BuiltinConfig, CaptureSource, RuntimeConfig, open_builtin_contexts from powercontext.builtin.runtime.composition import open_builtin_runtime from powercontext.builtin.scope import ScopeDraft -from powercontext.sources import SourceRef +from powercontext.sources import SourceRef, SourceSequenceLocator from tests.e2e.dream_support import memory_source_text SCOPE = "project" @@ -203,7 +203,9 @@ async def scenario() -> None: assert merged.ref.revision == 1 assert len(merged.artifact.lineage.artifacts) == len(originals) assert all(original.ref in merged.artifact.lineage.artifacts for original in originals) - assert merged.artifact.lineage.sources == (receipt.source_ref,) + assert merged.artifact.lineage.sources == ( + receipt.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=receipt.sequence)}), + ) for original in originals: current = await memory.get(original.ref.artifact_id) assert current.state.state == "merged" @@ -246,7 +248,11 @@ async def scenario() -> None: assert record.ref.revision == position assert record.artifact.content.text == fact assert record.artifact.lineage.sources == ( - SourceRef(source_type="content", source_id=source.source_id), + SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ), ) if originals: assert record.ref.artifact_id == originals[0].ref.artifact_id @@ -270,7 +276,11 @@ async def scenario() -> None: for request in reconciler.requests: (new_source,) = tuple(item for item in request.evidence if isinstance(item, AtomicMemoryEvidence)) source, raw = sources[new_source.journal_position - 1] - assert new_source.source_ref == SourceRef(source_type="content", source_id=source.source_id) + assert new_source.source_ref == SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ) assert memory_source_text(new_source) == raw assert request.proposal.evidence_ids == (new_source.evidence_id,) assert tuple( @@ -333,11 +343,21 @@ async def scenario() -> None: assert evidence.content.text == original.artifact.content.text assert evidence.content.text != continued.proposal.text else: - assert evidence.source_ref == SourceRef(source_type="content", source_id=source.source_id) + assert evidence.source_ref == SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ) assert memory_source_text(evidence) == latest (merged,) = (await memory.list()).items assert merged.artifact.content.text == latest - assert merged.artifact.lineage.sources == (SourceRef(source_type="content", source_id=source.source_id),) + assert merged.artifact.lineage.sources == ( + SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ), + ) assert len(merged.artifact.lineage.artifacts) == len(published) assert all(record.ref in merged.artifact.lineage.artifacts for record in published.values()) for original in originals: @@ -388,7 +408,13 @@ async def scenario() -> None: record for record in (await memory.list()).items if record.artifact.content.text == latest ) assert created.ref.artifact_id not in {original.ref.artifact_id for original in originals} - assert created.artifact.lineage.sources == (SourceRef(source_type="content", source_id=source.source_id),) + assert created.artifact.lineage.sources == ( + SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ), + ) assert len(created.artifact.lineage.artifacts) == len(originals) for original in originals: assert original.ref in created.artifact.lineage.artifacts @@ -420,12 +446,20 @@ async def scenario() -> None: assert flush.current_cursor == position original = next(item for item in (await memory.list()).items if item.artifact.content.text == text) assert original.state.state == "active" - assert original.artifact.lineage.sources == (SourceRef(source_type="content", source_id=source_id),) + assert original.artifact.lineage.sources == ( + SourceRef( + source_type="content", source_id=source_id, locator=SourceSequenceLocator(sequence=position) + ), + ) originals.append(original) assert len((await memory.list()).items) == 2 source = await contexts.records.capture_source(SCOPE, "content", "consolidate-rules", CONSOLIDATION, {}) - source_ref = SourceRef(source_type="content", source_id=source.source_id) + source_ref = SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ) result = await context.triggers.flush(limit=1) assert result.previous_cursor == 2 assert result.current_cursor == result.high_watermark == source.position == 3 @@ -541,7 +575,11 @@ async def fail_projection(connection, scope_id, artifact_id): assert retry.previous_cursor == 2 and retry.current_cursor == 3 (result,) = (await memory.list()).items assert result.artifact.content.text == POLICY - assert result.artifact.lineage.sources == (SourceRef(source_type="content", source_id="consolidate-rules"),) + assert result.artifact.lineage.sources == ( + SourceRef( + source_type="content", source_id="consolidate-rules", locator=SourceSequenceLocator(sequence=3) + ), + ) for original in originals: frozen = await memory.get(original.ref.artifact_id) assert frozen.state.merged_into_id == result.ref.artifact_id diff --git a/tests/e2e/test_atomic_memory_source_evidence.py b/tests/e2e/test_atomic_memory_source_evidence.py index fdf2688cd4..728737e790 100644 --- a/tests/e2e/test_atomic_memory_source_evidence.py +++ b/tests/e2e/test_atomic_memory_source_evidence.py @@ -22,8 +22,9 @@ from typing import cast import pytest -from pydantic import Field, field_serializer, field_validator -from sqlalchemy import event, text, update +from pydantic import BaseModel, Field, field_serializer, field_validator +from sqlalchemy import Select, event, text, update +from sqlalchemy.dialects import mysql from sqlalchemy.exc import SQLAlchemyError from powercontext.artifacts import ArtifactLineage @@ -49,7 +50,15 @@ from powercontext.builtin.runtime.relational import RelationalContexts from powercontext.builtin.scope import ScopeDraft from powercontext.builtin.sources import CONTENT_SOURCE_DEFINITION -from powercontext.sources import AdapterSourceDefinition, SourceDefinitionRegistry, SourceMaterialization, SourceRef +from powercontext.sources import ( + TEXT_EVIDENCE_PROJECTION_KEY, + AdapterSourceDefinition, + LocatedTextEvidence, + SourceDefinitionRegistry, + SourceMaterialization, + SourceRef, + SourceSequenceLocator, +) from powercontext.sources.evidence import MemoryEvidenceDeclaration from powercontext.sources.observations import manifest_for_definition, project_source_for_transport from tests.builtin.persistence.contract import NoteAdapter, NoteInput, NoteSource @@ -77,6 +86,79 @@ async def _commit(contexts, scope_id, artifact_id, text_value, *, sources=(), ar return result.primary +@pytest.mark.parametrize("representation", ["native", "observation"]) +def test_accepted_adapter_sequence_can_finish_memory_window(migration_backend, representation) -> None: + class SequenceAdapter(NoteAdapter): + async def read(self, source, /): + return LocatedTextEvidence( + source_type=self.name, + source_id=source.name, + content=source.body, + locator=SourceSequenceLocator(sequence=0), + ) + + adapter = SequenceAdapter() + + class SequenceProjection: + name = TEXT_EVIDENCE_PROJECTION_KEY.name + version = TEXT_EVIDENCE_PROJECTION_KEY.version + source_class: type[NoteSource] = NoteSource + output_class: type[BaseModel] = LocatedTextEvidence + + def project(self, source, /): + return LocatedTextEvidence( + source_type=adapter.name, + source_id=source.name, + content=source.body, + locator=SourceSequenceLocator(sequence=0), + ) + + definition = AdapterSourceDefinition(adapter, projections=(SequenceProjection(),)) + worker = SourceDefinitionRegistry((definition,)) + + class Extractor: + async def generate(self, request): + return GenerationResult( + output=AtomicMemoryExtractionOutput( + candidates=( + AtomicMemoryCandidate( + kind="fact", + text="Preserve the accepted remote fact.", + description="Accepted source evidence", + evidence_ids=tuple(item.evidence_id for item in request.evidence), + ), + ) + ) + ) + + async def scenario(): + config = BuiltinConfig(database=migration_backend.config) + registry = worker if representation == "native" else SourceDefinitionRegistry(()) + async with open_builtin_contexts( + config, source_registry=registry, candidate_pipeline=atomic_memory_pipeline(Extractor()) + ) as contexts: + context = await contexts.get("scope") + source = await worker.resolve(NoteInput(note_id="accepted", body="Preserve the accepted remote fact.")) + if representation == "observation": + await contexts.register_source_definition(manifest_for_definition(definition)) + await contexts.submit_source_observation( + SubmitSourceObservation(scope_id="scope", observation=project_source_for_transport(worker, source)) + ) + else: + await context.sources.add(source) + flush = await context.triggers.flush(limit=1) + assert flush.source_count == 1 and not flush.remaining_work + assert flush.current_cursor == 1 + memory = contexts.atomic_memory.for_scope("scope") + (record,) = (await memory.list()).items + assert record.artifact.lineage.sources == ( + SourceRef(source_type=adapter.name, source_id=source.name, locator=SourceSequenceLocator(sequence=1)), + ) + assert not (await context.triggers.flush(limit=1)).processed + + asyncio.run(scenario()) + + @pytest.mark.parametrize("declared", [False, True]) def test_unverifiable_native_serializer_keeps_memory_evidence_neutral(migration_backend, declared) -> None: class SerializedNote(NoteSource): @@ -500,3 +582,31 @@ async def scenario() -> None: assert await memory.evidence("rebuild", revision=1) == expected asyncio.run(scenario()) + + +def test_current_rebuild_locks_authoritative_heads(migration_backend: MigrationBackend) -> None: + """Check emitted locking semantics; SQLite execution does not certify native row locks.""" + + async def scenario(): + async with open_builtin_contexts(BuiltinConfig(database=migration_backend.config)) as contexts: + await _commit(contexts, "scope", "locked-rebuild", "Retain the current head.") + head_reads = [] + + def capture(_connection, clause, _multiparams, _params, _options): + if not isinstance(clause, Select): + return + statement = str(clause.compile(dialect=mysql.dialect())) + if "pc_artifact_heads LEFT OUTER JOIN pc_artifacts" in statement: + head_reads.append(statement) + + engine = contexts.database.engine.sync_engine + event.listen(engine, "before_execute", capture) + try: + async with contexts.database.transaction() as connection: + report = await rebuild_atomic_memory_current(connection, contexts.atomic_memory.index) + assert report.ready, report.errors + finally: + event.remove(engine, "before_execute", capture) + assert head_reads and head_reads[0].endswith("FOR UPDATE") + + asyncio.run(scenario()) diff --git a/tests/e2e/test_atomic_memory_supervisor_generation.py b/tests/e2e/test_atomic_memory_supervisor_generation.py index de9461769f..5ae8f76ca1 100644 --- a/tests/e2e/test_atomic_memory_supervisor_generation.py +++ b/tests/e2e/test_atomic_memory_supervisor_generation.py @@ -33,6 +33,7 @@ from powercontext.builtin.runtime.config import BuiltinConfig, InferenceConfig, RuntimeConfig from powercontext.builtin.runtime.family_processing import FAMILY_BINDINGS from powercontext.builtin.scope import ScopeDraft +from powercontext.sources import SourceSequenceLocator _MEMORY_TEXT = "The user prefers acceptance tests that exercise spawned workers." @@ -147,7 +148,9 @@ async def scenario() -> None: assert record.artifact.family == "atomic-memory" assert record.artifact.content.kind == "preference" assert record.artifact.content.text == _MEMORY_TEXT - assert record.artifact.lineage.sources == (receipt.source_ref,) + assert record.artifact.lineage.sources == ( + receipt.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=receipt.sequence)}), + ) assert record.state.state == "active" and record.state.state_version == 0 assert await memory.get(record.ref.artifact_id, revision=record.ref.revision) == record found = await memory.search("spawned workers", mode="text") diff --git a/tests/e2e/test_atomic_memory_wire_evidence.py b/tests/e2e/test_atomic_memory_wire_evidence.py index 0f3f744ae6..a9af38e770 100644 --- a/tests/e2e/test_atomic_memory_wire_evidence.py +++ b/tests/e2e/test_atomic_memory_wire_evidence.py @@ -22,6 +22,7 @@ from pydantic import BaseModel from powercontext.builtin.artifacts.atomic_memory.extraction import ( + AtomicMemoryCandidate, AtomicMemoryExtractionInput, AtomicMemoryExtractionOutput, ) @@ -34,8 +35,10 @@ from powercontext.sources import ( TEXT_EVIDENCE_PROJECTION_KEY, AdapterSourceDefinition, + LocatedTextEvidence, Source, SourceDefinitionRegistry, + SourceLineRangeLocator, SourceMaterialization, TextEvidence, ) @@ -160,3 +163,66 @@ async def scenario() -> None: serialized = extractor.requests[0].model_dump_json() assert serialized.count(BULK_BODY) == 1 assert character_token_estimator().estimate(serialized) < 24_000 + + +def test_adapter_text_position_survives_memory_generation_and_reopen(tmp_path) -> None: + locator = SourceLineRangeLocator(start_line=40, end_line=55, heading_path="部署 / 约束") + + class LocatedProjection(_TextProjection): + output_class: type[BaseModel] = LocatedTextEvidence + + def project(self, source: _WireSource) -> LocatedTextEvidence: + return LocatedTextEvidence( + source_type="wire-note", source_id=source.name, content=source.content, locator=locator + ) + + class Extractor: + async def generate(self, request: AtomicMemoryExtractionInput, /): + assert request.evidence[0].source_ref.locator == locator + return GenerationResult( + output=AtomicMemoryExtractionOutput( + candidates=( + AtomicMemoryCandidate( + kind="fact", + text=BODY, + description="Preserve the wire note fact.", + evidence_ids=(request.evidence[0].evidence_id,), + ), + ) + ) + ) + + async def scenario() -> None: + registry = SourceDefinitionRegistry(( + *BUILTIN_SOURCE_REGISTRY.definitions, + AdapterSourceDefinition(_WireAdapter(), projections=(LocatedProjection(),)), + )) + observation = project_source_for_transport( + registry, + _WireSource( + name="located", + materialization=SourceMaterialization.CAPTURED, + content=BODY, + wire_content=BODY, + ), + ) + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'located-memory.db'}")) + async with open_builtin_contexts(config, candidate_pipeline=atomic_memory_pipeline(Extractor())) as contexts: + await contexts.register_source_definition( + manifest_for_definition(registry.definition_for_name("wire-note")) + ) + await contexts.submit_source_observation( + SubmitSourceObservation(scope_id="scope-a", observation=observation) + ) + await contexts.process_memory("scope-a", 1) + (record,) = (await contexts.atomic_memory.for_scope("scope-a").list()).items + reference = record.ref + assert record.artifact.lineage.sources[0].locator == locator + async with open_builtin_contexts(config) as contexts: + loaded = await contexts.atomic_memory.for_scope("scope-a").get( + reference.artifact_id, revision=reference.revision + ) + assert loaded.artifact == record.artifact + assert loaded.artifact.lineage.sources[0].locator == locator + + asyncio.run(scenario()) diff --git a/tests/e2e/test_memory_write_gate_privacy.py b/tests/e2e/test_memory_write_gate_privacy.py index 9d46ef176a..428fd3e7d2 100644 --- a/tests/e2e/test_memory_write_gate_privacy.py +++ b/tests/e2e/test_memory_write_gate_privacy.py @@ -37,6 +37,7 @@ open_builtin_runtime, ) from powercontext.builtin.scope import ScopeDraft +from powercontext.sources import SourceSequenceLocator _FORBIDDEN = "synthetic-gate-forbidden-rationale" _MEMORY = "The user prefers SQLite restart regression tests." @@ -173,7 +174,9 @@ async def scenario(): assert flushed.processed and flushed.source_count == 1 records = (await memory.list()).items assert len(records) == 1 and records[0].artifact.content.text == _MEMORY - assert records[0].artifact.lineage.sources == (receipt.source_ref,) + assert records[0].artifact.lineage.sources == ( + receipt.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=receipt.sequence)}), + ) assert not (await memory.flush()).processed try: diff --git a/tests/e2e/test_rfc2086_worker_acceptance.py b/tests/e2e/test_rfc2086_worker_acceptance.py index fadae6225e..ffb43721ed 100644 --- a/tests/e2e/test_rfc2086_worker_acceptance.py +++ b/tests/e2e/test_rfc2086_worker_acceptance.py @@ -45,6 +45,7 @@ from powercontext.builtin.runtime.atomic_memory_input import ATOMIC_MEMORY_INPUT_SCHEMA from powercontext.builtin.runtime.family_processing import FAMILY_BINDINGS from powercontext.builtin.scope import ScopeDraft +from powercontext.sources import SourceSequenceLocator _FORBIDDEN = "synthetic-worker-forbidden-value" _MEMORY = "The user prefers tests exercising spawned worker recovery." @@ -224,7 +225,9 @@ async def scenario() -> None: records = (await reopened.atomic_memory.for_scope(scope.scope_id).list()).items assert len(records) == 1 assert records[0].artifact.content.text == _MEMORY - assert records[0].artifact.lineage.sources == (receipt.source_ref,) + assert records[0].artifact.lineage.sources == ( + receipt.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=receipt.sequence)}), + ) assert server.requests sent = [ json.loads(item["content"]) diff --git a/tests/e2e/test_source_citation_locators.py b/tests/e2e/test_source_citation_locators.py new file mode 100644 index 0000000000..4a21fd5c2a --- /dev/null +++ b/tests/e2e/test_source_citation_locators.py @@ -0,0 +1,159 @@ +# 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. + +"""Source citations survive real Server startup, persistence and both transports.""" + +import json +import sqlite3 + +from fastapi.testclient import TestClient + +from powercontext.builtin.persistence.migrations.deployment import deployment_runner +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.server.factory import create_server_app +from powercontext.server.settings import BearerAuthConfig, ServerSettings + + +def _handoff(refs): + return { + "family": "handoff", + "content": { + "schema": "powercontext.handoff.v1", + "objective": "核实项目约束", + "state": [ + {"text": "项目使用 Python。", "citations": [{"kind": "source", "source_ref": ref} for ref in refs]} + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + }, + } + + +def _settings(database): + return ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{database}"), + auth=BearerAuthConfig(enabled=False), + ) + + +def _app(database): + return create_server_app(settings=_settings(database)) + + +def test_locators_survive_upgrade_reopen_http_and_mcp(tmp_path): + database = tmp_path / "locators.db" + with TestClient(_app(database)) as client: + scope_response = client.post("/v1/scope-bindings/resolve", json={"binding_keys": []}) + assert scope_response.is_success, scope_response.text + scope = scope_response.json()["scope_id"] + response = client.post(f"/v1/scopes/{scope}/sources", json={"content": "项目使用 Python。"}) + assert response.is_success, response.text + source = response.json() + bare = {"name": "content", "source_id": source["source_id"]} + legacy = client.post(f"/v1/scopes/{scope}/artifacts", json=_handoff([bare])) + assert legacy.is_success, legacy.text + legacy_id = legacy.json()["artifact_id"] + + # Reconstruct the previous registered layout, preserving its real historical evidence. + with sqlite3.connect(database) as connection: + connection.execute("DROP TABLE pc_artifact_source_locators") + connection.execute("UPDATE pc_schema_revision SET version_num = 'v1_5_0_003'") + + runner = deployment_runner(_settings(database)) + plan = runner.plan() + assert plan.revisions == ("v1_5_0_004",) + runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) + + locators = [ + {"kind": "sequence", "sequence": source["position"]}, + {"kind": "line_range", "start_line": 40, "end_line": 55, "heading_path": "设计 / 约束"}, + {"kind": "line_range", "start_line": 60, "end_line": 65}, + ] + references = [bare | {"locator": locator} for locator in locators] + [bare] + expected = [ + { + "source_type": "content", + "source_id": bare["source_id"], + **({"locator": ref["locator"]} if "locator" in ref else {}), + } + for ref in references + ] + with TestClient(_app(database)) as client: + old = client.get(f"/v1/scopes/{scope}/artifacts/handoff/{legacy_id}") + assert old.is_success, old.text + assert [ref for ref in old.json()["sources"] if ref["source_id"] == bare["source_id"]] == [expected[-1]] + created = client.put( + f"/v1/scopes/{scope}/artifacts/handoff/{legacy_id}", + json={"content": _handoff(references)["content"]}, + headers={"If-Match": '"revision:1"'}, + ) + assert created.is_success, created.text + assert [ref for ref in created.json()["sources"] if ref["source_id"] == bare["source_id"]] == expected + artifact_id = created.json()["artifact_id"] + invalid = client.post( + f"/v1/scopes/{scope}/artifacts", + json=_handoff([bare | {"locator": {"kind": "line_range", "start_line": 1, "end_line": 201}}]), + ) + assert invalid.status_code == 422 + assert invalid.json()["error"]["code"] == "invalid_request" + wrong_position = client.put( + f"/v1/scopes/{scope}/artifacts/handoff/{legacy_id}", + json={ + "content": _handoff([ + bare | {"locator": {"kind": "sequence", "sequence": source["position"] + 100}}, + ])["content"] + }, + headers={"If-Match": '"revision:2"'}, + ) + assert wrong_position.status_code == 422, wrong_position.text + assert wrong_position.json()["error"]["code"] == "invalid_request" + + with TestClient(_app(database)) as client: + path = f"/v1/scopes/{scope}/artifacts/handoff/{artifact_id}" + read = client.get(path) + assert read.is_success, read.text + assert [ref for ref in read.json()["sources"] if ref["source_id"] == bare["source_id"]] == expected + assert read.json()["content"]["state"][0]["citations"] == [ + {"kind": "source", "source_ref": ref} for ref in expected + ] + result = client.post( + "/mcp/", + headers={"Accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_artifact", + "arguments": { + "scope_id": scope, + "family": "handoff", + "artifact_id": artifact_id, + }, + }, + }, + ) + assert result.is_success, result.text + message = next( + json.loads(line.removeprefix("data:")) for line in result.text.splitlines() if line.startswith("data:") + ) + assert "error" not in message, message + payload = message["result"] + assert not payload.get("isError"), payload + assert payload["structuredContent"]["artifact"] == read.json() + historical = client.get(path + "/revisions/1") + assert historical.is_success, historical.text + assert historical.json()["content"] == old.json()["content"] + assert historical.json()["sources"] == old.json()["sources"] diff --git a/tests/e2e/test_source_write.py b/tests/e2e/test_source_write.py index 3226c003a8..c3e0d29fa1 100644 --- a/tests/e2e/test_source_write.py +++ b/tests/e2e/test_source_write.py @@ -19,7 +19,10 @@ import asyncio import base64 import io +import json +import os import shutil +from pathlib import Path import httpx import pytest @@ -27,12 +30,70 @@ from fastmcp.exceptions import ToolError from PIL import Image +from powercontext.builtin.persistence.migrations.deployment import deployment_runner +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import RuntimeConfig +from powercontext.server.settings import ServerSettings from tests.source_helpers import ROOT, TOKENS, call, create_scope, discover, grant, load_write_cli, read, running_server EXAMPLE = ROOT / "examples/source_plugins/tickets" LOAD = "/v1/source-management/write/load" +def test_pre_locator_ticket_definition_keeps_write_and_collect_usable_after_restart(tmp_path): + """Reuse the declaration captured from the shipped plugin before locators existed.""" + + async def scenario(): + deployment = tmp_path / "tickets" + shutil.copytree(EXAMPLE, deployment, ignore=shutil.ignore_patterns("__pycache__")) + manifest = json.loads((ROOT / "tests/fixtures/tickets_manifest_before_locators.json").read_text()) + value = { + "namespace": "support", + "project": "powercontext", + "ticket_id": "PC-upgrade", + "title": "保留插件身份", + "body": "升级后继续写入。", + "labels": [], + "metadata": {}, + } + baseline = os.environ.get("POWERCONTEXT_TEST_SOURCE_BASE_ROOT") + environment = {"PYTHONPATH": str(Path(baseline) / "src")} if baseline else None + async with running_server(tmp_path, environment=environment) as (admin, url, _process): + scope = await create_scope(admin, "ticket-upgrade") + await grant(admin, scope, "deployer", "scope.viewer") + await grant(admin, scope) + registered = await admin.post("/v1/source-definitions/register", json={"manifest": manifest}) + assert registered.status_code == 200, registered.text + loaded = await load_write_cli(deployment / "write.yml", url) + async with Client(url + "/mcp", auth=TOKENS["host"]) as host: + args = {"scope_id": scope, "capability": "ticket.write", "capability_version": "1", "input": value} + captured = await call(host, "pc_source_add", args) + settings = ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}"), + runtime=RuntimeConfig(artifact_processing_families=()), + ) + runner = deployment_runner(settings) + plan = runner.plan() + if baseline: + assert plan.source_revision == "v1_5_0_003" + assert plan.revisions == ("v1_5_0_004",) + runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) + async with running_server(tmp_path) as (admin, url, _process): + assert await load_write_cli(deployment / "write.yml", url) == loaded + registered = await admin.post("/v1/source-definitions/register", json={"manifest": manifest}) + assert registered.status_code == 200, registered.text + async with Client(url + "/mcp", auth=TOKENS["host"]) as host: + assert any(item["name"] == "ticket.write" for item in (await discover(host, scope))["capabilities"]) + assert await call(host, "pc_source_add", args) == captured + updated = await call(host, "pc_source_add", {**args, "input": {**value, "body": "升级后的新快照。"}}) + assert updated["result"]["source_refs"] != captured["result"]["source_refs"] + assert (await read(admin, scope, captured["result"]["source_refs"][0]))["content"]["payload"][ + "capture" + ] == value + + asyncio.run(scenario()) + + def test_server_local_write_mcp_projection_and_restart(tmp_path): asyncio.run(_write_and_restart(tmp_path)) diff --git a/tests/e2e/test_zcode_service_chain.py b/tests/e2e/test_zcode_service_chain.py index 15f4ad2d51..f9b777562f 100644 --- a/tests/e2e/test_zcode_service_chain.py +++ b/tests/e2e/test_zcode_service_chain.py @@ -438,7 +438,13 @@ def run_scope(action: str, *extra: str) -> dict[str, Any]: f"/revisions/{reference['revision']}" ) revision.raise_for_status() - assert revision.json()["sources"] == [{"source_type": "content", "source_id": captured[0]["source_id"]}] + assert revision.json()["sources"] == [ + { + "source_type": "content", + "source_id": captured[0]["source_id"], + "locator": {"kind": "sequence", "sequence": captured[0]["position"]}, + } + ] second = _invoke_hook( node=node, diff --git a/tests/e2e/zcode_acceptance/runner.py b/tests/e2e/zcode_acceptance/runner.py index 70a7d50ff6..ae258e0d6b 100644 --- a/tests/e2e/zcode_acceptance/runner.py +++ b/tests/e2e/zcode_acceptance/runner.py @@ -123,7 +123,8 @@ def __enter__(self) -> RestartableServer: def start(self) -> None: listener = socket.socket() - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if os.name != "nt": + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("127.0.0.1", self.port)) self.port = listener.getsockname()[1] self.server = uvicorn.Server( @@ -628,7 +629,19 @@ def automatic_memory(self) -> None: assert published["scope_id"] == self.scope_id assert {key: published[key] for key in reference} == reference assert published["content"]["text"] == generated["text"] - if {"source_type": "content", "source_id": source["source_id"]} in published["sources"]: + published_source = next( + ( + item + for item in published["sources"] + if item["source_type"] == "content" and item["source_id"] == source["source_id"] + ), + None, + ) + if published_source: + assert published_source.get("locator") == { + "kind": "sequence", + "sequence": source["position"], + }, "automatic_memory_source_position_mismatch" break generated = None time.sleep(0.1) diff --git a/tests/fixtures/text_evidence_manifest_before_locators.json b/tests/fixtures/text_evidence_manifest_before_locators.json new file mode 100644 index 0000000000..e99572530b --- /dev/null +++ b/tests/fixtures/text_evidence_manifest_before_locators.json @@ -0,0 +1,311 @@ +{ + "name": "content", + "version": "1", + "source_schema": { + "$defs": { + "ArtifactRef": { + "description": "A stable reference to one exact artifact revision.", + "properties": { + "family": { + "title": "Family", + "type": "string" + }, + "artifact_id": { + "title": "Artifact Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "family", + "artifact_id", + "revision" + ], + "title": "ArtifactRef", + "type": "object" + }, + "ArtifactRestorationOutcome": { + "additionalProperties": false, + "description": "One successful restoration group retained with its primary revision.", + "properties": { + "operation": { + "enum": [ + "restore", + "undo_merge" + ], + "title": "Operation", + "type": "string" + }, + "target": { + "$ref": "#/$defs/ArtifactRef" + }, + "writes": { + "items": { + "$ref": "#/$defs/ArtifactRestorationWrite" + }, + "title": "Writes", + "type": "array" + }, + "undo_merge_results": { + "items": { + "$ref": "#/$defs/ArtifactRef" + }, + "title": "Undo Merge Results", + "type": "array" + } + }, + "required": [ + "operation", + "target", + "writes", + "undo_merge_results" + ], + "title": "ArtifactRestorationOutcome", + "type": "object" + }, + "ArtifactRestorationWrite": { + "additionalProperties": false, + "description": "Exact content source and committed result of one restoration write.", + "properties": { + "before_ref": { + "$ref": "#/$defs/ArtifactRef" + }, + "content_from_ref": { + "$ref": "#/$defs/ArtifactRef" + }, + "after_ref": { + "$ref": "#/$defs/ArtifactRef" + }, + "lifecycle_state": { + "enum": [ + "active", + "retired" + ], + "title": "Lifecycle State", + "type": "string" + } + }, + "required": [ + "before_ref", + "content_from_ref", + "after_ref", + "lifecycle_state" + ], + "title": "ArtifactRestorationWrite", + "type": "object" + }, + "ContentSourceInternal": { + "description": "Server-owned Source purpose data never exposed by the base REST API.", + "properties": { + "role": { + "const": "lineage_only", + "title": "Role", + "type": "string" + }, + "operation": { + "enum": [ + "artifact_create", + "artifact_replace", + "artifact_restore", + "artifact_undo_merge" + ], + "title": "Operation", + "type": "string" + }, + "target": { + "$ref": "#/$defs/ContentSourceTarget" + }, + "restoration_outcome": { + "anyOf": [ + { + "$ref": "#/$defs/ArtifactRestorationOutcome" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "role", + "operation", + "target" + ], + "title": "ContentSourceInternal", + "type": "object" + }, + "ContentSourceTarget": { + "description": "Exact Artifact revision to which one system Source is bound.", + "properties": { + "scope_id": { + "title": "Scope Id", + "type": "string" + }, + "family": { + "maxLength": 128, + "minLength": 1, + "title": "Family", + "type": "string" + }, + "artifact_id": { + "title": "Artifact Id", + "type": "string" + }, + "revision": { + "default": 1, + "minimum": 1, + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "scope_id", + "family", + "artifact_id" + ], + "title": "ContentSourceTarget", + "type": "object" + }, + "JsonValue": {}, + "SourceMaterialization": { + "description": "Describe where the value read for a Source comes from.", + "enum": [ + "captured", + "referenced" + ], + "title": "SourceMaterialization", + "type": "string" + } + }, + "description": "Captured content that can be used as Artifact evidence.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "definition_version": { + "default": "1", + "title": "Definition Version", + "type": "string" + }, + "materialization": { + "$ref": "#/$defs/SourceMaterialization" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "content": { + "title": "Content", + "type": "string" + }, + "metadata": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "title": "Metadata", + "type": "object" + }, + "working_note": { + "default": false, + "title": "Working Note", + "type": "boolean" + }, + "wire_content": { + "anyOf": [ + { + "$ref": "#/$defs/JsonValue" + }, + { + "type": "null" + } + ], + "default": null + }, + "wire_content_present": { + "default": false, + "title": "Wire Content Present", + "type": "boolean" + }, + "internal": { + "anyOf": [ + { + "$ref": "#/$defs/ContentSourceInternal" + }, + { + "type": "null" + } + ], + "default": null + }, + "handoff_receipt": { + "default": false, + "title": "Handoff Receipt", + "type": "boolean" + } + }, + "required": [ + "name", + "materialization", + "content" + ], + "title": "ContentSource", + "type": "object" + }, + "projections": [ + { + "key": { + "name": "powercontext.text-evidence", + "version": "1" + }, + "schema": { + "$defs": { + "JsonValue": {} + }, + "description": "Canonical JSON shape consumed as textual Artifact evidence.", + "properties": { + "source_type": { + "title": "Source Type", + "type": "string" + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "metadata": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "title": "Metadata", + "type": "object" + } + }, + "required": [ + "source_type", + "source_id", + "content" + ], + "title": "TextEvidence", + "type": "object" + } + } + ], + "fingerprint": "sha256:4baf43bbfbfe384d33ecb2c8bfb79fca303a065bf9a51545af1de3fb34a87c99" +} diff --git a/tests/fixtures/tickets_manifest_before_locators.json b/tests/fixtures/tickets_manifest_before_locators.json new file mode 100644 index 0000000000..db3a563074 --- /dev/null +++ b/tests/fixtures/tickets_manifest_before_locators.json @@ -0,0 +1,208 @@ +{ + "name": "example-ticket", + "version": "1", + "fingerprint": "sha256:6c8a2da1eaa5d37193944e627160838339e28c0ecfd2cc84643c84f3e53604a7", + "source_schema": { + "$defs": { + "JsonValue": {}, + "SourceMaterialization": { + "description": "Describe where the value read for a Source comes from.", + "enum": [ + "captured", + "referenced" + ], + "title": "SourceMaterialization", + "type": "string" + }, + "TicketCapture": { + "additionalProperties": false, + "properties": { + "namespace": { + "minLength": 1, + "title": "Namespace", + "type": "string" + }, + "project": { + "minLength": 1, + "title": "Project", + "type": "string" + }, + "ticket_id": { + "minLength": 1, + "title": "Ticket Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "body": { + "title": "Body", + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "metadata": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "title": "Metadata", + "type": "object" + } + }, + "required": [ + "namespace", + "project", + "ticket_id", + "title", + "body" + ], + "title": "TicketCapture", + "type": "object" + } + }, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "definition_version": { + "default": "1", + "title": "Definition Version", + "type": "string" + }, + "materialization": { + "$ref": "#/$defs/SourceMaterialization" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "capture": { + "$ref": "#/$defs/TicketCapture" + } + }, + "required": [ + "name", + "materialization", + "capture" + ], + "title": "TicketSource", + "type": "object" + }, + "projections": [ + { + "key": { + "name": "example.ticket-details", + "version": "1" + }, + "schema": { + "$defs": { + "JsonValue": {} + }, + "additionalProperties": false, + "properties": { + "namespace": { + "minLength": 1, + "title": "Namespace", + "type": "string" + }, + "project": { + "minLength": 1, + "title": "Project", + "type": "string" + }, + "ticket_id": { + "minLength": 1, + "title": "Ticket Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "body": { + "title": "Body", + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "metadata": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "title": "Metadata", + "type": "object" + } + }, + "required": [ + "namespace", + "project", + "ticket_id", + "title", + "body" + ], + "title": "TicketCapture", + "type": "object" + } + }, + { + "key": { + "name": "powercontext.text-evidence", + "version": "1" + }, + "schema": { + "$defs": { + "JsonValue": {} + }, + "description": "Canonical JSON shape consumed as textual Artifact evidence.", + "properties": { + "source_type": { + "title": "Source Type", + "type": "string" + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "metadata": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "title": "Metadata", + "type": "object" + } + }, + "required": [ + "source_type", + "source_id", + "content" + ], + "title": "TextEvidence", + "type": "object" + } + } + ] +} diff --git a/tests/native/test_oceanbase_engine_recovery.py b/tests/native/test_oceanbase_engine_recovery.py index 0a11c51009..5e9ae7da8d 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_5_0_003" and result.readiness_scope == "server" + assert result.state == "ready" and result.revision == "v1_5_0_004" 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 ea2e5f69fb..af304fa261 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_5_0_003" + assert result["database_state"] == "ready" and result["revision"] == "v1_5_0_004" 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/test_api_contract.py b/tests/test_api_contract.py index 5748ad889e..e1ab731de2 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -843,7 +843,7 @@ def test_source_reference_keeps_name_as_the_source_type() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) properties = contract["components"]["schemas"]["SourceReference"]["properties"] - assert set(properties) == {"name", "source_id"} + assert set(properties) == {"name", "source_id", "locator"} def test_memory_transport_has_no_legacy_citation_shape() -> None: @@ -1114,8 +1114,8 @@ def test_base_access_uses_a_dedicated_source_type_reference() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) schemas = contract["components"]["schemas"] - assert set(schemas["SourceReference"]["properties"]) == {"name", "source_id"} - assert set(schemas["SourceTypeReference"]["properties"]) == {"source_type", "source_id"} + assert set(schemas["SourceReference"]["properties"]) == {"name", "source_id", "locator"} + assert set(schemas["SourceTypeReference"]["properties"]) == {"source_type", "source_id", "locator"} assert schemas["SourcePage"]["required"] == ["items", "next_cursor"] assert schemas["SourcePage"]["properties"]["items"]["items"] == {"$ref": "#/components/schemas/SourceRecord"} for schema_name in ("ArtifactCreated", "ArtifactRevision", "ArtifactCollectionItem"): diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 450c804fba..50992d4929 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_5_0_003" + assert plan["target_revision"] == "v1_5_0_004" 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_5_0_003",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] 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 7377ea3eeb..989ca7e786 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_5_0_003",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 89f7799b42..3de63bddb7 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -64,9 +64,10 @@ def invoke(action, *options): "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_5_0_003", "name": "v1_5_0_003", "software_version": "1.5.0", "sequence": 3} + target = {"revision": "v1_5_0_004", "name": "v1_5_0_004", "software_version": "1.5.0", "sequence": 4} assert plan["target_migration"] == target applied = invoke( @@ -81,7 +82,7 @@ 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_5_0_003",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] @@ -96,6 +97,7 @@ def invoke(action, *options): "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", + "v1_5_0_004", } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index 5809d3781d..1a1e73264c 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -66,7 +66,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_5_0_003" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_004" assert plan["readiness_scope"] == "server" assert plan["compatibility_steps"] == [] full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) diff --git a/tests/test_source_locators.py b/tests/test_source_locators.py new file mode 100644 index 0000000000..2cfbd7b7ca --- /dev/null +++ b/tests/test_source_locators.py @@ -0,0 +1,95 @@ +# 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. + +import pytest +from pydantic import ValidationError + +from powercontext.sources import SourceRef + + +@pytest.mark.parametrize( + "locator", + [ + {"kind": "sequence", "sequence": 0}, + {"kind": "sequence", "sequence": 12}, + {"kind": "line_range", "start_line": 40, "end_line": 55, "heading_path": "设计 / 引用"}, + {"kind": "line_range", "start_line": 1, "end_line": 200}, + ], +) +def test_source_locator_round_trip(locator) -> None: + reference = {"source_type": "document", "source_id": "doc", "locator": locator} + value = SourceRef.model_validate(reference) + assert value.model_dump(mode="json", exclude_none=True) == reference + assert SourceRef.model_validate_json(value.model_dump_json()) == value + + +def test_absent_source_locator_preserves_legacy_encoding() -> None: + reference = {"source_type": "content", "source_id": "doc"} + value = SourceRef.model_validate(reference) + assert value.locator is None + assert value.model_dump(mode="json") == reference + origin = SourceRef.model_validate(reference | {"locator": {"kind": "sequence", "sequence": 0}}) + assert origin != value + assert origin.model_dump(mode="json")["locator"]["sequence"] == 0 + + +@pytest.mark.parametrize( + "locator", + [ + {"kind": "sequence", "sequence": -1}, + {"kind": "sequence", "sequence": True}, + {"kind": "sequence", "sequence": "1"}, + {"kind": "sequence", "sequence": 1, "start_line": 1}, + {"kind": "line_range", "start_line": 0, "end_line": 1}, + {"kind": "line_range", "start_line": 2, "end_line": 1}, + {"kind": "line_range", "start_line": 1, "end_line": 201}, + {"kind": "line_range", "start_line": 1.0, "end_line": 1}, + {"kind": "line_range", "start_line": 1, "end_line": 1, "heading_path": "x" * 1025}, + {"kind": "unknown", "sequence": 0}, + ], +) +def test_invalid_source_locator_is_rejected(locator) -> None: + with pytest.raises(ValidationError): + SourceRef.model_validate({"source_type": "document", "source_id": "doc", "locator": locator}) + + +def test_source_locator_round_trips_both_transport_spellings() -> None: + from powercontext.server.mapping import ( + runtime_source_reference, + runtime_source_type_reference, + source_reference, + source_type_reference, + ) + + reference = SourceRef.model_validate({ + "source_type": "document", + "source_id": "doc", + "locator": {"kind": "line_range", "start_line": 1, "end_line": 200}, + }) + assert runtime_source_reference(source_reference(reference)) == reference + assert runtime_source_type_reference(source_type_reference(reference)) == reference + bare = SourceRef(source_type="content", source_id="old") + assert "locator" not in source_reference(bare).model_dump(mode="json") + assert "locator" not in source_type_reference(bare).model_dump(mode="json") + + +def test_transport_rejects_excessive_source_line_span() -> None: + from powercontext.http import SourceReference + + with pytest.raises(ValidationError): + SourceReference.model_validate({ + "name": "document", + "source_id": "doc", + "locator": {"kind": "line_range", "start_line": 1, "end_line": 201}, + }) diff --git a/tests/test_source_observations.py b/tests/test_source_observations.py index be10272920..6ba452eae3 100644 --- a/tests/test_source_observations.py +++ b/tests/test_source_observations.py @@ -749,3 +749,27 @@ async def scenario() -> None: assert receipt.source_ref.source_id == source.name asyncio.run(scenario()) + + +def test_pre_locator_text_evidence_manifest_retains_its_fingerprint() -> None: + import json + from pathlib import Path + + from powercontext.builtin.sources.observation_validation import ( + validate_source_definition_manifest, + validate_source_observation, + ) + + payload = json.loads((Path(__file__).parent / "fixtures/text_evidence_manifest_before_locators.json").read_text()) + manifest = SourceDefinitionManifest.model_validate(payload) + validate_source_definition_manifest(manifest) + assert manifest.model_dump(mode="json", by_alias=True) == payload + assert manifest_for_definition(CONTENT_SOURCE_DEFINITION) == manifest + + async def scenario() -> None: + registry = SourceDefinitionRegistry((CONTENT_SOURCE_DEFINITION,)) + source = await registry.resolve(ContentCapture(source_id="legacy", content="原始证据")) + observation = project_source_for_transport(registry, source) + validate_source_observation(observation, manifest) + + asyncio.run(scenario()) diff --git a/uv.lock b/uv.lock index 914a0d950f..61d5e392c1 100644 --- a/uv.lock +++ b/uv.lock @@ -3098,7 +3098,7 @@ requires-dist = [ { name = "pycasbin", marker = "extra == 'ops'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'seekdb'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'server'", specifier = ">=2.8,<3" }, - { name = "pydantic", specifier = ">=2.10,<3" }, + { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'cli'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'ops'", specifier = ">=2.27.1,<3" },