linkedparticles-core 1.210.2__tar.gz → 1.214.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/PKG-INFO +1 -1
  2. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/config.py +411 -27
  3. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/shacl.py +8 -1
  4. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/schema.py +4 -0
  5. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/mcp_memory.py +31 -1
  6. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/rdf.py +37 -0
  7. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/structure.py +13 -8
  8. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/adapters/openai_compat.py +44 -0
  9. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/render/markdown.py +200 -33
  10. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/url_safety.py +124 -1
  11. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/pyproject.toml +2 -2
  12. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/_client_fixtures.py +11 -4
  13. linkedparticles_core-1.214.1/tests/test_config_path_guard.py +72 -0
  14. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/LICENSE +0 -0
  15. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/NOTICE +0 -0
  16. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/README.md +0 -0
  17. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/conformance/README.md +0 -0
  18. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/conformance/profile.yaml +0 -0
  19. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/conformance/similarity_vectors.json +0 -0
  20. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/context.jsonld +0 -0
  21. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/interchange.schema.json +0 -0
  22. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/particle.schema.json +0 -0
  23. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/shacl/CorpusSnapshotShape.ttl +0 -0
  24. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/shacl/ParticleShape.ttl +0 -0
  25. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/shacl/ProvenanceChainShape.ttl +0 -0
  26. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/shacl/SubjectShape.ttl +0 -0
  27. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/shacl/TrustStatementShape.ttl +0 -0
  28. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/artifacts/schemas/trust_lens.schema.json +0 -0
  29. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/constraints.txt +0 -0
  30. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/__init__.py +0 -0
  31. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/__init__.py +0 -0
  32. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/_resources.py +0 -0
  33. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/contract.py +0 -0
  34. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/fixtures.py +0 -0
  35. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/jsonschema.py +0 -0
  36. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/profile.py +0 -0
  37. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/runner.py +0 -0
  38. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/types.py +0 -0
  39. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/conformance/validator.py +0 -0
  40. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/__init__.py +0 -0
  41. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/_resources.py +0 -0
  42. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/artifact_fold.py +0 -0
  43. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/cascade_gate.py +0 -0
  44. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/claims.py +0 -0
  45. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/closure_measure.py +0 -0
  46. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/conflict_resolution.py +0 -0
  47. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/conflict_review.py +0 -0
  48. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/consolidation_cadence.py +0 -0
  49. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/contradiction_disclosure.py +0 -0
  50. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/duplicate_key.py +0 -0
  51. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/equivalence.py +0 -0
  52. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/fingerprint.py +0 -0
  53. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/generics.py +0 -0
  54. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/granularity.py +0 -0
  55. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/held_release.py +0 -0
  56. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/jsonld_context.py +0 -0
  57. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/ladders.py +0 -0
  58. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/modality.py +0 -0
  59. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/observer_scope.py +0 -0
  60. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/past_value.py +0 -0
  61. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/predicate_profile.py +0 -0
  62. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/probe_verdict.py +0 -0
  63. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/progress.py +0 -0
  64. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/provenance.py +0 -0
  65. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/reanchor.py +0 -0
  66. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/rung_ladder.py +0 -0
  67. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/schema_version.py +0 -0
  68. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/scoring/__init__.py +0 -0
  69. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/scoring/confidence.py +0 -0
  70. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/scoring/decay.py +0 -0
  71. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/scoring/relevance.py +0 -0
  72. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/scoring/utility.py +0 -0
  73. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/spend_budget.py +0 -0
  74. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/stance.py +0 -0
  75. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/status.py +0 -0
  76. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/validity.py +0 -0
  77. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/vocabulary.py +0 -0
  78. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/vocabulary_jsonld.py +0 -0
  79. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/core/vocabulary_report.py +0 -0
  80. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/egress.py +0 -0
  81. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/embeddings.py +0 -0
  82. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/__init__.py +0 -0
  83. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/append_delta.py +0 -0
  84. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/calibration.py +0 -0
  85. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/components.py +0 -0
  86. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/docstrings.py +0 -0
  87. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/general.py +0 -0
  88. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/github/__init__.py +0 -0
  89. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/github/_shared.py +0 -0
  90. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/github/gist.py +0 -0
  91. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/github/pages.py +0 -0
  92. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/github/repo.py +0 -0
  93. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/hackernews.py +0 -0
  94. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/incremental.py +0 -0
  95. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/journal.py +0 -0
  96. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/mastodon.py +0 -0
  97. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/migration_preview.py +0 -0
  98. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/modality.py +0 -0
  99. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/nomisma.py +0 -0
  100. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/numista/__init__.py +0 -0
  101. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/numista/_shared.py +0 -0
  102. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/numista/coin.py +0 -0
  103. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/numista/issuer.py +0 -0
  104. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/numista/listing.py +0 -0
  105. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/polarity.py +0 -0
  106. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/property_keys.py +0 -0
  107. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/reddit.py +0 -0
  108. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/registry.py +0 -0
  109. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/scope.py +0 -0
  110. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/session_artifacts.py +0 -0
  111. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/session_state.py +0 -0
  112. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/subject_gate.py +0 -0
  113. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/subject_scope.py +0 -0
  114. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/taxonomy.py +0 -0
  115. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/tool_turns.py +0 -0
  116. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/trust_lens.py +0 -0
  117. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/vocabulary.py +0 -0
  118. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/extraction/wikidata.py +0 -0
  119. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/http.py +0 -0
  120. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/interchange/__init__.py +0 -0
  121. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/interchange/codec.py +0 -0
  122. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/interchange/jsonl.py +0 -0
  123. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/interchange/yaml_ld.py +0 -0
  124. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/__init__.py +0 -0
  125. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/adapters/__init__.py +0 -0
  126. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/adapters/anthropic.py +0 -0
  127. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/adapters/local.py +0 -0
  128. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/batch_budget.py +0 -0
  129. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/breaker.py +0 -0
  130. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/client.py +0 -0
  131. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/errors.py +0 -0
  132. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/fencing.py +0 -0
  133. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/logprobs.py +0 -0
  134. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/pool.py +0 -0
  135. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/registry.py +0 -0
  136. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/replay.py +0 -0
  137. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/spend_budget.py +0 -0
  138. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/llm/usage.py +0 -0
  139. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/py.typed +0 -0
  140. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/render/__init__.py +0 -0
  141. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/secrets.py +0 -0
  142. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/particles/url_canonical.py +0 -0
  143. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/__init__.py +0 -0
  144. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/_upstream.py +0 -0
  145. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/MANIFEST.yaml +0 -0
  146. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/hackernews-thread-001/content.bin +0 -0
  147. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/hackernews-thread-001/manifest.yaml +0 -0
  148. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/hackernews-thread-001/snapshot.json +0 -0
  149. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/journal-entry-001/content.bin +0 -0
  150. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/journal-entry-001/manifest.yaml +0 -0
  151. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/journal-entry-001/snapshot.json +0 -0
  152. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/mastodon-thread-001/content.bin +0 -0
  153. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/mastodon-thread-001/manifest.yaml +0 -0
  154. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/mastodon-thread-001/snapshot.json +0 -0
  155. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/mcp-memory-export-001/content.bin +0 -0
  156. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/mcp-memory-export-001/manifest.yaml +0 -0
  157. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/mcp-memory-export-001/snapshot.json +0 -0
  158. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/nomisma-concept-001/content.bin +0 -0
  159. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/nomisma-concept-001/manifest.yaml +0 -0
  160. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/nomisma-concept-001/snapshot.json +0 -0
  161. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-coin-001/content.bin +0 -0
  162. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-coin-001/manifest.yaml +0 -0
  163. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-coin-001/snapshot.json +0 -0
  164. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-issuer-001/content.bin +0 -0
  165. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-issuer-001/manifest.yaml +0 -0
  166. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-issuer-001/snapshot.json +0 -0
  167. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-listing-001/content.bin +0 -0
  168. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-listing-001/manifest.yaml +0 -0
  169. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/numista-listing-001/snapshot.json +0 -0
  170. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/rdf-graph-001/content.bin +0 -0
  171. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/rdf-graph-001/manifest.yaml +0 -0
  172. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/rdf-graph-001/snapshot.json +0 -0
  173. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/reddit-thread-001/content.bin +0 -0
  174. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/reddit-thread-001/manifest.yaml +0 -0
  175. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/reddit-thread-001/snapshot.json +0 -0
  176. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/taxonomy-coins-001/content.bin +0 -0
  177. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/taxonomy-coins-001/manifest.yaml +0 -0
  178. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/taxonomy-coins-001/snapshot.json +0 -0
  179. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/trust-lens-001/content.bin +0 -0
  180. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/trust-lens-001/manifest.yaml +0 -0
  181. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/trust-lens-001/snapshot.json +0 -0
  182. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-article-001/content.bin +0 -0
  183. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-article-001/manifest.yaml +0 -0
  184. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-article-001/snapshot.json +0 -0
  185. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-article-002/content.bin +0 -0
  186. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-article-002/manifest.yaml +0 -0
  187. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-article-002/snapshot.json +0 -0
  188. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-essay-001/content.bin +0 -0
  189. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-essay-001/manifest.yaml +0 -0
  190. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-essay-001/snapshot.json +0 -0
  191. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-interview-001/content.bin +0 -0
  192. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-interview-001/manifest.yaml +0 -0
  193. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/web-interview-001/snapshot.json +0 -0
  194. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/wikidata-entity-001/content.bin +0 -0
  195. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/wikidata-entity-001/manifest.yaml +0 -0
  196. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conformance/fixtures/wikidata-entity-001/snapshot.json +0 -0
  197. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/conftest.py +0 -0
  198. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/fixtures/scope/normative-rule-file.md +0 -0
  199. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/fixtures/scope/normative-rule-file.recorded.json +0 -0
  200. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/fixtures/scope/rule-file-agents-md.md +0 -0
  201. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/fixtures/scope/rule-file-agents-md.recorded.json +0 -0
  202. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_calibration_isotonic.py +0 -0
  203. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_confidence.py +0 -0
  204. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_config_client_sections.py +0 -0
  205. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_config_sample.py +0 -0
  206. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_conformance_fixture_gen.py +0 -0
  207. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_conformance_similarity_vectors.py +0 -0
  208. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_conformance_validator.py +0 -0
  209. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_core_contradiction_disclosure.py +0 -0
  210. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_core_held_release.py +0 -0
  211. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_core_observer_scope.py +0 -0
  212. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_core_provenance.py +0 -0
  213. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_core_vocabulary.py +0 -0
  214. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_egress_policy.py +0 -0
  215. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_embeddings.py +0 -0
  216. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_extraction_vision.py +0 -0
  217. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_interchange_schema.py +0 -0
  218. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_llm_batch_budget.py +0 -0
  219. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_llm_errors.py +0 -0
  220. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_llm_logprobs.py +0 -0
  221. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_llm_pool.py +0 -0
  222. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_llm_replay.py +0 -0
  223. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_llm_usage.py +0 -0
  224. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_schema.py +0 -0
  225. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_schema_openness.py +0 -0
  226. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_secrets.py +0 -0
  227. {linkedparticles_core-1.210.2 → linkedparticles_core-1.214.1}/tests/test_url_canonical.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: linkedparticles-core
3
- Version: 1.210.2
3
+ Version: 1.214.1
4
4
  Summary: Store-free client library for Particles: define, validate, serialize, and extract provenance-tracked belief particles
5
5
  Project-URL: Homepage, https://linkedparticles.org
6
6
  Project-URL: Documentation, https://docs.linkedparticles.org
@@ -16,9 +16,11 @@ from __future__ import annotations
16
16
  import ipaddress
17
17
  import logging
18
18
  import os
19
+ import stat
19
20
  from collections.abc import Callable, Mapping
20
21
  from pathlib import Path
21
- from typing import Any, Literal, TypeVar
22
+ from typing import Any, Literal, NamedTuple, TypeVar
23
+ from urllib.parse import urlsplit
22
24
 
23
25
  import yaml
24
26
  from pydantic import BaseModel, Field, field_validator, model_validator
@@ -222,6 +224,25 @@ class ApiConfig(BaseModel):
222
224
  audit log), and ``/digest`` (the provenance-ranked belief digest) — are
223
225
  gated by the bearer **regardless** of this flag; under the ``dev-key``
224
226
  loopback skip they stay open for local development.
227
+
228
+ ``allowed_origins`` lists browser origins (``scheme://host[:port]``) whose
229
+ cross-site requests the engine serves without a verified bearer. Every gated
230
+ route refuses a cross-site browser request otherwise (security review
231
+ 2026-10-23: any web page could POST a multipart deposit to a dev-key
232
+ loopback engine). The engine's own web UI at ``/app`` is same-origin and
233
+ needs no entry. **Default empty**; a wildcard or ``null`` is rejected.
234
+
235
+ ``allowed_hosts`` lists the ``Host`` header names (no port) the engine
236
+ answers on, beside the loopback names (``localhost``, ``127.0.0.0/8``,
237
+ ``::1``), which are always allowed so a local health probe keeps working.
238
+ A ``*.`` prefix matches any subdomain (``*.tailnet.example``). The check
239
+ covers every path, ``/health`` and ``/app`` included, and is what refuses a
240
+ DNS-rebinding page: such a page reaches the engine's address under its own
241
+ hostname (security review 2026-10-24, N1). Under the dev-key skip only the
242
+ loopback names and these entries are served, whatever this list holds.
243
+ With a real ``PARTICLES_API_KEY`` and an empty list (the default) no
244
+ ``Host`` is refused; set it on a non-loopback deployment whose reads are
245
+ open, and include the name your health probe sends.
225
246
  """
226
247
 
227
248
  max_request_body_bytes: int = 25 * 1024 * 1024 # 25 MiB
@@ -229,6 +250,57 @@ class ApiConfig(BaseModel):
229
250
  trusted_proxies: list[str] = Field(default_factory=list)
230
251
  rate_limit_per_minute: int = 60
231
252
  require_auth_for_reads: bool = False
253
+ allowed_origins: list[str] = Field(default_factory=list)
254
+ allowed_hosts: list[str] = Field(default_factory=list)
255
+
256
+ @field_validator("allowed_hosts")
257
+ @classmethod
258
+ def _validate_allowed_hosts(cls, value: list[str]) -> list[str]:
259
+ """Accept bare host names, optionally with a leading ``*.`` wildcard.
260
+
261
+ A ``Host`` header is compared without its port, so an entry carrying a
262
+ scheme, a path or a port could never match. A bare ``*`` would match
263
+ every rebinding page, which is the attack the list exists to refuse.
264
+ """
265
+ normalized: list[str] = []
266
+ for entry in value:
267
+ host = entry.strip().lower()
268
+ bare = host.removeprefix("*.")
269
+ # A bracketed IPv6 literal carries colons of its own.
270
+ forbidden = "/@ " if bare.startswith("[") and bare.endswith("]") else "/:@ "
271
+ if not bare or "*" in bare or any(c in bare for c in forbidden):
272
+ raise ValueError(
273
+ f"api.allowed_hosts entry {entry!r} is not a host name; give a "
274
+ "bare name with no scheme or port, e.g. engine.example.org or "
275
+ "*.tailnet.example"
276
+ )
277
+ normalized.append(host)
278
+ return normalized
279
+
280
+ @field_validator("allowed_origins")
281
+ @classmethod
282
+ def _validate_allowed_origins(cls, value: list[str]) -> list[str]:
283
+ """Accept only exact ``scheme://host[:port]`` origins.
284
+
285
+ A browser sends exactly that form, so anything else (a path, a
286
+ wildcard, the opaque ``null`` origin a sandboxed frame or a ``file:``
287
+ page sends) could never match a real origin, or would match every page.
288
+ """
289
+ for entry in value:
290
+ parts = urlsplit(entry.strip())
291
+ if (
292
+ not parts.scheme
293
+ or not parts.netloc
294
+ or "*" in entry
295
+ or parts.path not in ("", "/")
296
+ or parts.query
297
+ or parts.fragment
298
+ ):
299
+ raise ValueError(
300
+ f"api.allowed_origins entry {entry!r} is not an origin; give "
301
+ "scheme://host[:port], e.g. https://notes.example.org"
302
+ )
303
+ return value
232
304
 
233
305
  @field_validator("trusted_proxies")
234
306
  @classmethod
@@ -319,10 +391,18 @@ class EngineConfig(BaseModel):
319
391
  bearer token the client presents is a *secret* and is read via
320
392
  ``particles.secrets.get_engine_token_optional`` (``PARTICLES_ENGINE_TOKEN``)
321
393
  — never a field here, never in ``config.yaml``.
394
+
395
+ ``allow_plaintext_token`` lets the bearer ride a non-loopback ``http://``
396
+ URL, with a warning. Off by default: without it the client refuses to send
397
+ the token there at all (completing the June review's F18), so
398
+ a token reaches a remote engine only over ``https://`` or a loopback hop
399
+ (an SSH or Tailscale forward). Set it only for an engine on a network you
400
+ trust end to end.
322
401
  """
323
402
 
324
403
  base_url: str | None = None
325
404
  timeout_seconds: float = 60.0
405
+ allow_plaintext_token: bool = False
326
406
 
327
407
 
328
408
  class ObservabilityConfig(BaseModel):
@@ -359,6 +439,11 @@ class ObservabilityConfig(BaseModel):
359
439
  # local collector, or a SaaS ingest URL). Non-secret; the credential is the
360
440
  # PARTICLES_OTEL_EXPORTER_HEADERS secret. Unset ⇒ the OTLP SDK default endpoint.
361
441
  endpoint: str | None = None
442
+ # Exporter headers (PARTICLES_OTEL_EXPORTER_HEADERS, OTEL_EXPORTER_OTLP_*HEADERS)
443
+ # are refused over a non-loopback http:// endpoint: no OTLP exporter is
444
+ # installed and one WARNING names why (N12). True sends them anyway, with
445
+ # the warning, for a collector on a network you trust end to end.
446
+ allow_plaintext_headers: bool = False
362
447
  # Per-signal enables — turn off a signal without disabling the whole layer.
363
448
  traces: bool = True
364
449
  metrics: bool = True
@@ -428,6 +513,10 @@ class OpenAICompatProviderConfig(BaseModel):
428
513
  # OpenAI-compatible base URL; the adapter appends ``/chat/completions``.
429
514
  # Default targets a local Ollama server (the case).
430
515
  base_url: str = "http://localhost:11434/v1"
516
+ # The entry's API key is refused over a non-loopback http:// base_url, before
517
+ # any request (N12; the engine token's rule). True sends it
518
+ # anyway, with a warning, for an endpoint on a network you trust end to end.
519
+ allow_plaintext_key: bool = False
431
520
  # Per-request wall-clock timeout (seconds). Local models on modest hardware
432
521
  # can be slow, so the default is generous.
433
522
  timeout_seconds: float = 120.0
@@ -1490,6 +1579,14 @@ class MigrationConfig(BaseModel):
1490
1579
  # Deliberately low. A migrated belief is second-hand: this store never saw
1491
1580
  # the claim made, cannot check it, and did not calibrate the number.
1492
1581
  import_confidence: float = Field(default=0.35, ge=0.0, le=1.0)
1582
+ # Bounds on one export (security review N23). An export larger than
1583
+ # ``max_bytes`` is not parsed, and the mapping stops at ``max_candidates``
1584
+ # with a quality note naming what it left out. Each candidate is reconciled
1585
+ # with an embedding and the §6.6 ladder, and an HTTP text deposit typed
1586
+ # ``MCP_MEMORY_EXPORT`` reaches this extractor too, so the export is not
1587
+ # trusted to be small.
1588
+ max_bytes: int = Field(default=32 * 1024 * 1024, ge=1)
1589
+ max_candidates: int = Field(default=10_000, ge=1)
1493
1590
 
1494
1591
 
1495
1592
  class WebClipperConfig(BaseModel):
@@ -2872,6 +2969,31 @@ class LocalRefreshConfig(BaseModel):
2872
2969
  # *identity* rather than of content, which an unattended pass should decline
2873
2970
  # to follow rather than silently ingest.
2874
2971
  follow_symlinks: bool = False
2972
+ # Extra directories (or files) the refresh may read, beyond the derived
2973
+ # roots. The tier reads a ``file://`` entry only when its resolved path is
2974
+ # under one of these or a derived root (security review D2): the rule-file
2975
+ # roots (``rule_sources.paths``, the roots ``rules sync`` and
2976
+ # ``init claude-code`` recorded, the main checkout of a recorded worktree,
2977
+ # and ``~/.claude``; never the working directory), admitting only
2978
+ # files named in ``rule_sources.filenames`` there, and ``*.md`` under
2979
+ # ``~/.claude/projects`` (the Claude Code memory files). A file deposited
2980
+ # with ``--fetch-policy LAZY`` anywhere else needs its directory named here.
2981
+ # ``~`` and ``$VAR`` are expanded. An entry outside every root is skipped
2982
+ # like a missing file and logged.
2983
+ allowed_roots: list[str] = Field(default_factory=list)
2984
+
2985
+
2986
+ class LocalFilesConfig(BaseModel):
2987
+ """Bounds on the local files the corpus reads itself (security review C9).
2988
+
2989
+ Every local read the corpus makes goes through one descriptor-checked reader
2990
+ (``particles.corpus.local_read``): a file deposit, a vault, web-clipper or
2991
+ project walk, a rule-file intake and the local refresh tier. ``max_bytes``
2992
+ caps the file it will read; a larger file is refused before a byte is read
2993
+ and reported like any unreadable file.
2994
+ """
2995
+
2996
+ max_bytes: int = Field(default=256 * 1024 * 1024, ge=1)
2875
2997
 
2876
2998
 
2877
2999
  class RuleSourcesConfig(BaseModel):
@@ -2938,7 +3060,8 @@ class McpWriteConfig(BaseModel):
2938
3060
  enabled_stores: list[str] = Field(default_factory=list)
2939
3061
  # Seeded AUTHOR-scoped trust_rank for a new asserter identity (§6/§6a).
2940
3062
  agent_trust_rank: float = Field(default=0.8, ge=0.0, le=1.0)
2941
- # Whether retract/supersede may target another identity's particles (§6).
3063
+ # Whether retract / supersede / tag / untag / link may target another
3064
+ # identity's particles, and link_remove another writer's link (§6).
2942
3065
  allow_cross_asserter: bool = False
2943
3066
  # The server-bound asserting principal stamped onto asserted_by / author_id
2944
3067
  # / event actor — NOT a per-call client argument (§4a/§6, M3).
@@ -3012,6 +3135,18 @@ class McpMemoryCompatConfig(BaseModel):
3012
3135
 
3013
3136
 
3014
3137
  class McpConfig(BaseModel):
3138
+ """MCP server surfaces.
3139
+
3140
+ ``rate_limit_per_minute`` caps, per server process, the tool calls that can
3141
+ drive a paid model call: a ``query`` with a question (the NL response), a
3142
+ ``particle_assert`` / ``particle_supersede`` (the §6.6 contradiction
3143
+ probe), and every write of the memory-server façade. One token bucket per
3144
+ process, refilling over a minute, so an agent looping on a tool (a prompt
3145
+ injection, a runaway plan) spends at most this many such calls a minute
3146
+ (security review D30). ``0`` disables it. Other reads are not counted.
3147
+ """
3148
+
3149
+ rate_limit_per_minute: int = Field(default=60, ge=0)
3015
3150
  write: McpWriteConfig = Field(default_factory=McpWriteConfig)
3016
3151
  recall: McpRecallConfig = Field(default_factory=McpRecallConfig)
3017
3152
  memory_compat: McpMemoryCompatConfig = Field(default_factory=McpMemoryCompatConfig)
@@ -4299,9 +4434,16 @@ class CliConfig(BaseModel):
4299
4434
  config rather than behind a flag; set to 0 to disable entirely. The
4300
4435
  heartbeat is already suppressed whenever stderr is not a TTY, so this knob
4301
4436
  only matters for interactive use.
4437
+
4438
+ ``warn_unused_config`` controls the one warning a process prints when a
4439
+ Particles ``config.yaml`` sits in its working directory or an ancestor and
4440
+ is not read (settings come only from ``PARTICLES_CONFIG`` or
4441
+ ``~/.particles/config.yaml``). Only a file the loader reads can turn it
4442
+ off, so a file it ignores never can.
4302
4443
  """
4303
4444
 
4304
4445
  heartbeat_seconds: float = Field(default=20.0, ge=0)
4446
+ warn_unused_config: bool = True
4305
4447
 
4306
4448
 
4307
4449
  class SourcePassageConfig(BaseModel):
@@ -4407,6 +4549,7 @@ class ParticlesConfig(BaseModel):
4407
4549
  benchmark: BenchmarkConfig = Field(default_factory=BenchmarkConfig)
4408
4550
  refetch_floors: dict[str, int] = Field(default_factory=lambda: dict(_DEFAULT_REFETCH_FLOORS))
4409
4551
  local_refresh: LocalRefreshConfig = Field(default_factory=LocalRefreshConfig)
4552
+ local_files: LocalFilesConfig = Field(default_factory=LocalFilesConfig)
4410
4553
  rule_sources: RuleSourcesConfig = Field(default_factory=RuleSourcesConfig)
4411
4554
 
4412
4555
  def reconciliation_mode_for(self, store: str | None) -> str:
@@ -4488,6 +4631,7 @@ class ParticlesConfig(BaseModel):
4488
4631
  #: carve itself is reserved, not taken.
4489
4632
  CLIENT_SECTIONS: frozenset[str] = frozenset(
4490
4633
  {
4634
+ "cli",
4491
4635
  "confidence",
4492
4636
  "content_age_decay",
4493
4637
  "contestedness",
@@ -4587,49 +4731,265 @@ def env_override_names() -> frozenset[str]:
4587
4731
  return frozenset(name for name, _section, _field in _ENV_OVERRIDES)
4588
4732
 
4589
4733
 
4734
+ #: Environment variables that choose where a secret, a prompt or telemetry is
4735
+ #: sent, and beat the config file a process pins: the two Particles overrides
4736
+ #: above, plus the ones the Anthropic and OpenTelemetry SDKs read themselves.
4737
+ ENDPOINT_ENV_VARS: tuple[str, ...] = (
4738
+ "PARTICLES_ENGINE_BASE_URL",
4739
+ "PARTICLES_OBSERVABILITY_ENDPOINT",
4740
+ "ANTHROPIC_BASE_URL",
4741
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
4742
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
4743
+ "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
4744
+ "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
4745
+ )
4746
+
4747
+
4748
+ def _endpoint_origin(value: str) -> str:
4749
+ """``scheme://host[:port]`` of an endpoint value, without any userinfo or path."""
4750
+ try:
4751
+ parts = urlsplit(value)
4752
+ host = parts.hostname or ""
4753
+ port = f":{parts.port}" if parts.port is not None else ""
4754
+ except ValueError:
4755
+ return "(unparseable)"
4756
+ return f"{parts.scheme}://{host}{port}" if parts.scheme and host else "(unparseable)"
4757
+
4758
+
4759
+ def environment_notes() -> list[str]:
4760
+ """What this process's environment changes about its config, for the diagnostics.
4761
+
4762
+ ``config validate`` and ``hook doctor`` print these: a relative
4763
+ ``PARTICLES_CONFIG`` (read against the working directory), every set
4764
+ override from :data:`_ENV_OVERRIDES`, and each endpoint variable that
4765
+ sends a secret or the prompts somewhere other than the pinned file says
4766
+ (security review N15, N16). An entry starting with ``⚠`` deserves a look.
4767
+ """
4768
+ notes: list[str] = []
4769
+ explicit = os.environ.get("PARTICLES_CONFIG")
4770
+ if explicit and not Path(explicit).is_absolute():
4771
+ notes.append(
4772
+ f"⚠ PARTICLES_CONFIG is relative ({explicit}) and is read against the working "
4773
+ "directory; set an absolute path"
4774
+ )
4775
+ overrides = sorted(name for name in env_override_names() if name in os.environ)
4776
+ if overrides:
4777
+ notes.append("overrides set: " + ", ".join(overrides))
4778
+ for name in ENDPOINT_ENV_VARS:
4779
+ value = os.environ.get(name)
4780
+ if value:
4781
+ notes.append(f"⚠ {name} sends to {_endpoint_origin(value)}, overriding the config file")
4782
+ return notes
4783
+
4784
+
4590
4785
  # ---------------------------------------------------------------------------
4591
4786
  # Loader
4592
4787
  # ---------------------------------------------------------------------------
4593
4788
 
4594
4789
 
4790
+ def user_config_path() -> Path:
4791
+ """The per-user config file, ``~/.particles/config.yaml``.
4792
+
4793
+ Fixed under the home directory, beside the per-user state Particles already
4794
+ keeps there, and never configurable from a config. Computed per call so a
4795
+ test that swaps ``HOME`` sees its own.
4796
+ """
4797
+ return Path.home() / ".particles" / "config.yaml"
4798
+
4799
+
4595
4800
  def _find_config_file() -> Path | None:
4596
- """Resolve the ``config.yaml`` this process should load.
4597
-
4598
- Order: ``PARTICLES_CONFIG`` (absolute authority — a named file that does
4599
- not exist resolves to ``None`` rather than falling through), then the
4600
- working directory, then the nearest ancestor holding a ``config.yaml``,
4601
- first match wins.
4602
-
4603
- The upward walk exists because falling back to compiled defaults is
4604
- *silent*: a process launched from a subdirectory — a git worktree, a hook
4605
- spawn, a ``cd`` into ``scripts/`` — used to lose every knob the operator
4606
- set at the repo root, and the divergence surfaced weeks later as blobs
4607
- written somewhere nobody looks (the 2026-07-18 sharding incident).
4608
-
4609
- The walk stops after examining the directory that holds a ``.git`` entry,
4610
- so a ``config.yaml`` in ``$HOME`` never becomes the ambient config for
4611
- every project beneath it: a config at a repo root is a deliberate project
4612
- setting, one above it is an accident waiting to be inherited.
4801
+ """Resolve the ``config.yaml`` this process loads.
4802
+
4803
+ Order: ``PARTICLES_CONFIG``, then the user file
4804
+ (:func:`user_config_path`), then nothing (compiled defaults).
4805
+ ``PARTICLES_CONFIG`` is absolute authority: a named file that does not
4806
+ exist resolves to ``None`` rather than falling through to the user file,
4807
+ and :func:`_load_config` reports it.
4808
+
4809
+ Nothing is read from the working directory or its ancestors. The loader
4810
+ used to walk up from the working directory, which let a cloned
4811
+ repository that ships a ``config.yaml`` configure every unpinned process run
4812
+ inside it: rebind the engine, provider and telemetry endpoints the secrets
4813
+ are sent to, or turn on the MCP write tools (security review 2026-10-23,
4814
+ findings D1, D6, D8 and D15). A nearby file that is no longer read is
4815
+ reported instead (:func:`nearby_unused_config`).
4613
4816
  """
4614
4817
  explicit = os.environ.get("PARTICLES_CONFIG")
4615
4818
  if explicit:
4616
4819
  p = Path(explicit)
4617
4820
  return p if p.exists() else None
4821
+ user = user_config_path()
4822
+ return user if user.exists() else None
4823
+
4824
+
4825
+ #: Largest nearby file :func:`nearby_unused_config` will parse to see whether it
4826
+ #: is a Particles config. A larger file is not reported; it is read for no
4827
+ #: other purpose.
4828
+ _NEARBY_CONFIG_MAX_BYTES = 1024 * 1024
4829
+
4830
+
4831
+ def _read_bounded_regular_file(path: Path, limit: int) -> bytes | None:
4832
+ """At most ``limit`` bytes of ``path``, or ``None`` when it is not a small regular file.
4618
4833
 
4619
- start = Path.cwd()
4834
+ The file is opened non-blocking and checked with ``fstat`` on the open
4835
+ descriptor, so a FIFO or device swapped in after an ``is_file()`` check
4836
+ can neither hang the read nor feed it without end (security review N14).
4837
+ """
4838
+ try:
4839
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
4840
+ except OSError:
4841
+ return None
4842
+ try:
4843
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
4844
+ return None
4845
+ chunks: list[bytes] = []
4846
+ remaining = limit + 1
4847
+ while remaining > 0:
4848
+ chunk = os.read(fd, remaining)
4849
+ if not chunk:
4850
+ break
4851
+ chunks.append(chunk)
4852
+ remaining -= len(chunk)
4853
+ except OSError:
4854
+ return None
4855
+ finally:
4856
+ os.close(fd)
4857
+ data = b"".join(chunks)
4858
+ return None if len(data) > limit else data
4859
+
4860
+
4861
+ def _names_a_particles_section(path: Path) -> bool:
4862
+ """Whether ``path`` is a YAML mapping naming at least one Particles section.
4863
+
4864
+ ``yaml.safe_load`` builds plain values only, so parsing a file nobody
4865
+ vouched for applies nothing. It can still fail in ways a YAML error does
4866
+ not cover (a deeply nested document raises ``RecursionError``), and this
4867
+ only decides whether to print a warning, so any failure means "no"
4868
+ (security review N14).
4869
+ """
4870
+ data = _read_bounded_regular_file(path, _NEARBY_CONFIG_MAX_BYTES)
4871
+ if data is None:
4872
+ return False
4873
+ try:
4874
+ loaded = yaml.safe_load(data.decode("utf-8"))
4875
+ except Exception: # noqa: BLE001 - a warning path must never fail the load
4876
+ return False
4877
+ return isinstance(loaded, dict) and any(key in ParticlesConfig.model_fields for key in loaded)
4878
+
4879
+
4880
+ def nearby_unused_config(loaded: Path | None = None) -> Path | None:
4881
+ """The nearest Particles ``config.yaml`` around the working directory, if unread.
4882
+
4883
+ Looks in the working directory and its ancestors, stopping after the
4884
+ directory that holds a ``.git`` entry (a file in a git worktree), the
4885
+ bounds walk used. Returns the nearest ``config.yaml`` when it
4886
+ names a Particles section and is not the file the loader read (``loaded``);
4887
+ another tool's ``config.yaml`` returns ``None``. Never applies anything.
4888
+ """
4889
+ loaded_real = loaded.resolve() if loaded is not None else None
4890
+ try:
4891
+ start = Path.cwd()
4892
+ except OSError: # the working directory was removed
4893
+ return None
4620
4894
  for directory in (start, *start.parents):
4621
4895
  candidate = directory / "config.yaml"
4622
- if candidate.exists():
4623
- return candidate
4624
- # `exists()`, not `is_dir()`: in a git worktree (and in a submodule)
4625
- # `.git` is a *file* containing a `gitdir:` pointer. That is precisely
4626
- # the case this bound has to catch, since worktrees are where the
4627
- # subdirectory-launch problem showed up.
4896
+ if candidate.is_file():
4897
+ if loaded_real is not None and candidate.resolve() == loaded_real:
4898
+ return None
4899
+ return candidate if _names_a_particles_section(candidate) else None
4628
4900
  if (directory / ".git").exists():
4629
4901
  return None
4630
4902
  return None
4631
4903
 
4632
4904
 
4905
+ class ConfigResolution(NamedTuple):
4906
+ """Where this process's settings come from, for ``config validate`` and ``hook doctor``.
4907
+
4908
+ ``origin`` is ``"PARTICLES_CONFIG"``, ``"user file"`` or ``"none"``
4909
+ (compiled defaults). ``missing`` is the path ``PARTICLES_CONFIG`` names when
4910
+ that file does not exist; ``ignored`` is a nearby Particles config the
4911
+ loader does not read (:func:`nearby_unused_config`).
4912
+ """
4913
+
4914
+ path: Path | None
4915
+ origin: str
4916
+ missing: Path | None
4917
+ ignored: Path | None
4918
+
4919
+
4920
+ def describe_config_resolution() -> ConfigResolution:
4921
+ """Resolve the config origin without loading or caching it."""
4922
+ explicit = os.environ.get("PARTICLES_CONFIG")
4923
+ path = _find_config_file()
4924
+ if explicit:
4925
+ origin = "PARTICLES_CONFIG" if path is not None else "none"
4926
+ missing = None if path is not None else Path(explicit)
4927
+ return ConfigResolution(path, origin, missing, None)
4928
+ origin = "user file" if path is not None else "none"
4929
+ return ConfigResolution(path, origin, None, nearby_unused_config(path))
4930
+
4931
+
4932
+ _warned_resolution: set[str] = set()
4933
+ _resolution_warnings: list[str] = []
4934
+
4935
+
4936
+ def _warn_resolution_once(key: str, message: str) -> None:
4937
+ """Log one config-resolution warning per process, and keep it for the hook log."""
4938
+ if key in _warned_resolution:
4939
+ return
4940
+ _warned_resolution.add(key)
4941
+ _resolution_warnings.append(message)
4942
+ log.warning("%s", message)
4943
+
4944
+
4945
+ def config_resolution_warnings() -> list[str]:
4946
+ """The config-resolution warnings this process has emitted (§3).
4947
+
4948
+ The lifecycle hooks print nothing but their own output, so they copy these
4949
+ into the hook log instead.
4950
+ """
4951
+ return list(_resolution_warnings)
4952
+
4953
+
4954
+ def _report_resolution(config_path: Path | None, cfg: ParticlesConfig) -> None:
4955
+ """Warn about a missing or relative ``PARTICLES_CONFIG``, or a nearby config left unread."""
4956
+ explicit = os.environ.get("PARTICLES_CONFIG")
4957
+ if explicit:
4958
+ if config_path is not None and not Path(explicit).is_absolute():
4959
+ _warn_resolution_once(
4960
+ f"relative:{explicit}",
4961
+ f"PARTICLES_CONFIG names a relative path ({explicit}), so it is read against "
4962
+ f"the working directory as {config_path.resolve()}; a cloned repository with "
4963
+ "a file at that path would configure this process. Set an absolute path.",
4964
+ )
4965
+ if config_path is None:
4966
+ hint = (
4967
+ " The shell does not expand `~` inside quotes; write $HOME/... instead."
4968
+ if explicit.startswith("~")
4969
+ else ""
4970
+ )
4971
+ _warn_resolution_once(
4972
+ f"missing:{explicit}",
4973
+ f"PARTICLES_CONFIG names {explicit}, which does not exist; "
4974
+ f"running on compiled defaults.{hint}",
4975
+ )
4976
+ return
4977
+ if not cfg.cli.warn_unused_config:
4978
+ return
4979
+ try:
4980
+ nearby = nearby_unused_config(config_path)
4981
+ except Exception: # noqa: BLE001 - a warning path must never fail the load (N14)
4982
+ log.debug("nearby config check failed", exc_info=True)
4983
+ return
4984
+ if nearby is not None:
4985
+ _warn_resolution_once(
4986
+ f"ignored:{nearby}",
4987
+ f"Ignoring {nearby}: Particles no longer reads config.yaml from the working "
4988
+ f"directory. To use it, set PARTICLES_CONFIG={nearby}, or move it to "
4989
+ f"{user_config_path()}.",
4990
+ )
4991
+
4992
+
4633
4993
  def sqlite_file_path(url: str) -> str | None:
4634
4994
  """Return a file-based SQLite URL's on-disk path, or ``None`` for memory/non-SQLite.
4635
4995
 
@@ -4647,6 +5007,28 @@ def sqlite_file_path(url: str) -> str | None:
4647
5007
  return path
4648
5008
 
4649
5009
 
5010
+ def describe_default_store() -> str:
5011
+ """Where the ``default`` store is, for a startup or diagnostic line.
5012
+
5013
+ A file-backed SQLite store reads as its absolute path, marked when the
5014
+ configured path is relative and so names a different file in each working
5015
+ directory. Any other DSN reads as its scheme only, so no credential in it
5016
+ is printed.
5017
+ """
5018
+ url = get_config().storage.database_url
5019
+ db_file = sqlite_file_path(url)
5020
+ if db_file is None:
5021
+ return f"{url.split(':', 1)[0]} database"
5022
+ path = Path(db_file).expanduser()
5023
+ if path.is_absolute():
5024
+ return str(path)
5025
+ try:
5026
+ here = Path.cwd() / path
5027
+ except OSError:
5028
+ here = path
5029
+ return f"{os.path.normpath(here)} (relative to the working directory)"
5030
+
5031
+
4650
5032
  def resolve_store_adjacent_path(value: str) -> Path:
4651
5033
  """Resolve a store-adjacent path, anchoring relative values to the store.
4652
5034
 
@@ -4714,7 +5096,9 @@ def _load_config() -> ParticlesConfig:
4714
5096
  else:
4715
5097
  raw[section][field] = value
4716
5098
 
4717
- return ParticlesConfig.model_validate(raw)
5099
+ cfg = ParticlesConfig.model_validate(raw)
5100
+ _report_resolution(config_path, cfg)
5101
+ return cfg
4718
5102
 
4719
5103
 
4720
5104
  def validate_config() -> tuple[Path | None, ParticlesConfig]:
@@ -120,12 +120,19 @@ def _validate(doc: dict[str, Any], shape_name: str) -> ValidationResult:
120
120
  import pyshacl # type: ignore[import-untyped,unused-ignore]
121
121
  import rdflib
122
122
 
123
+ from particles.extraction.rdf import no_remote_retrieval
124
+
123
125
  # Use Graph (not Dataset) for the data side: our particle documents are
124
126
  # flat JSON-LD with no @graph array, so a single default graph is enough.
125
127
  # Dataset triggers rdflib's own DeprecationWarnings for default_context
126
128
  # and identifier in current rdflib releases.
127
129
  data_graph = rdflib.Graph()
128
- data_graph.parse(data=json.dumps(doc), format="json-ld")
130
+ # The document is caller-supplied, so a string @context or an @import
131
+ # in it would make rdflib fetch that URL, with no SSRF check (D43,
132
+ # SECURITY_REVIEW_20261023-1). The serializers below embed the context
133
+ # inline; a document that names one remotely is refused, not fetched.
134
+ with no_remote_retrieval():
135
+ data_graph.parse(data=json.dumps(doc), format="json-ld")
129
136
  shapes_graph = rdflib.Graph().parse(str(shape_path), format="turtle")
130
137
 
131
138
  conforms, report_graph, report_text = pyshacl.validate(
@@ -331,6 +331,10 @@ class RelationCreatedBy(StrEnum):
331
331
  # Written on a CONTRADICTS edge; the probe verdict it records was already
332
332
  # paid for on the write path, so lint reports it without a second probe.
333
333
  OBSERVER_DIVERGENCE = "OBSERVER_DIVERGENCE"
334
+ # An MCP agent's `link_add`. Kept distinct from MANUAL_CLI, the
335
+ # operator's `links add`, so an agent may withdraw only the links an agent
336
+ # wrote and the event log credits the agent, not the operator CLI.
337
+ AGENT_ASSERTED = "AGENT_ASSERTED"
334
338
 
335
339
 
336
340
  class SuggestMode(StrEnum):