never4ga 0.1.0__py3-none-any.whl

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 (204) hide show
  1. never4ga/__init__.py +15 -0
  2. never4ga/adapters/__init__.py +6 -0
  3. never4ga/adapters/clients/__init__.py +7 -0
  4. never4ga/adapters/clients/mcp_registry.py +74 -0
  5. never4ga/adapters/fakes/__init__.py +47 -0
  6. never4ga/adapters/fakes/agent_adapter.py +38 -0
  7. never4ga/adapters/fakes/document_store.py +80 -0
  8. never4ga/adapters/fakes/extension_registry.py +57 -0
  9. never4ga/adapters/fakes/graph_index.py +112 -0
  10. never4ga/adapters/fakes/index_state.py +56 -0
  11. never4ga/adapters/fakes/maintenance_findings.py +44 -0
  12. never4ga/adapters/fakes/memory.py +58 -0
  13. never4ga/adapters/fakes/metadata_index.py +59 -0
  14. never4ga/adapters/fakes/repository_locator.py +104 -0
  15. never4ga/adapters/fakes/secret_store.py +37 -0
  16. never4ga/adapters/fakes/service_manager.py +39 -0
  17. never4ga/adapters/fakes/session_store.py +102 -0
  18. never4ga/adapters/fakes/text_index.py +115 -0
  19. never4ga/adapters/fakes/tracker_cache.py +47 -0
  20. never4ga/adapters/fakes/vault_files.py +49 -0
  21. never4ga/adapters/fakes/work_management.py +278 -0
  22. never4ga/adapters/fakes/workspace_mappings.py +24 -0
  23. never4ga/adapters/filesystem/__init__.py +54 -0
  24. never4ga/adapters/filesystem/client_files.py +79 -0
  25. never4ga/adapters/filesystem/deployments.py +153 -0
  26. never4ga/adapters/filesystem/document_store.py +541 -0
  27. never4ga/adapters/filesystem/extension_registry.py +210 -0
  28. never4ga/adapters/filesystem/frontmatter.py +226 -0
  29. never4ga/adapters/filesystem/repository_locator.py +142 -0
  30. never4ga/adapters/filesystem/secret_store.py +165 -0
  31. never4ga/adapters/filesystem/vault_files.py +100 -0
  32. never4ga/adapters/filesystem/workspace_mappings.py +233 -0
  33. never4ga/adapters/git/__init__.py +14 -0
  34. never4ga/adapters/git/signals.py +158 -0
  35. never4ga/adapters/null/__init__.py +20 -0
  36. never4ga/adapters/null/memory.py +62 -0
  37. never4ga/adapters/null/service_manager.py +40 -0
  38. never4ga/adapters/null/workspace_mappings.py +27 -0
  39. never4ga/adapters/openproject/__init__.py +33 -0
  40. never4ga/adapters/openproject/api.py +341 -0
  41. never4ga/adapters/openproject/normalise.py +211 -0
  42. never4ga/adapters/openproject/provider.py +453 -0
  43. never4ga/adapters/openproject/write.py +207 -0
  44. never4ga/adapters/openproject/writer.py +531 -0
  45. never4ga/adapters/sqlite/__init__.py +40 -0
  46. never4ga/adapters/sqlite/connection.py +445 -0
  47. never4ga/adapters/sqlite/graph_index.py +192 -0
  48. never4ga/adapters/sqlite/index_state.py +211 -0
  49. never4ga/adapters/sqlite/maintenance_findings.py +96 -0
  50. never4ga/adapters/sqlite/metadata_index.py +236 -0
  51. never4ga/adapters/sqlite/session_store.py +292 -0
  52. never4ga/adapters/sqlite/sessions.py +192 -0
  53. never4ga/adapters/sqlite/text_index.py +322 -0
  54. never4ga/adapters/sqlite/tracker_cache.py +187 -0
  55. never4ga/adapters/sqlite/trackers.py +72 -0
  56. never4ga/adapters/systemd/__init__.py +22 -0
  57. never4ga/adapters/systemd/service_manager.py +183 -0
  58. never4ga/adapters/work_management.py +103 -0
  59. never4ga/api/__init__.py +13 -0
  60. never4ga/api/app.py +1021 -0
  61. never4ga/api/errors.py +290 -0
  62. never4ga/api/models.py +752 -0
  63. never4ga/build.py +118 -0
  64. never4ga/cli/__init__.py +4225 -0
  65. never4ga/cli/__main__.py +10 -0
  66. never4ga/cli/connections.py +150 -0
  67. never4ga/cli/output.py +75 -0
  68. never4ga/cli/work.py +108 -0
  69. never4ga/client_descriptors.py +146 -0
  70. never4ga/clients/antigravity-cli.toml +32 -0
  71. never4ga/clients/claude-code.toml +35 -0
  72. never4ga/clients/codex-cli.toml +27 -0
  73. never4ga/composition.py +493 -0
  74. never4ga/config.py +293 -0
  75. never4ga/context/__init__.py +7 -0
  76. never4ga/context/assembler.py +125 -0
  77. never4ga/context/authority.py +110 -0
  78. never4ga/context/budget.py +304 -0
  79. never4ga/context/deep.py +108 -0
  80. never4ga/context/focused.py +323 -0
  81. never4ga/context/foreign.py +55 -0
  82. never4ga/context/freshness.py +37 -0
  83. never4ga/context/fusion.py +123 -0
  84. never4ga/context/inbox_signals.py +148 -0
  85. never4ga/context/required.py +71 -0
  86. never4ga/context/scope.py +149 -0
  87. never4ga/context/session_signals.py +104 -0
  88. never4ga/context/signals.py +39 -0
  89. never4ga/context/structural.py +652 -0
  90. never4ga/domain/__init__.py +5 -0
  91. never4ga/domain/capabilities.py +120 -0
  92. never4ga/domain/chunk.py +100 -0
  93. never4ga/domain/clients.py +174 -0
  94. never4ga/domain/configuration.py +30 -0
  95. never4ga/domain/connections.py +86 -0
  96. never4ga/domain/context.py +331 -0
  97. never4ga/domain/deployments.py +85 -0
  98. never4ga/domain/document.py +183 -0
  99. never4ga/domain/extensions.py +354 -0
  100. never4ga/domain/identity.py +172 -0
  101. never4ga/domain/memory.py +92 -0
  102. never4ga/domain/provenance.py +119 -0
  103. never4ga/domain/relations.py +82 -0
  104. never4ga/domain/retrieval.py +65 -0
  105. never4ga/domain/scope.py +61 -0
  106. never4ga/domain/sessions.py +243 -0
  107. never4ga/domain/signals.py +77 -0
  108. never4ga/domain/text_hazards.py +150 -0
  109. never4ga/domain/vendoring.py +184 -0
  110. never4ga/domain/work_policy.py +147 -0
  111. never4ga/errors/__init__.py +278 -0
  112. never4ga/errors/reporting.py +37 -0
  113. never4ga/indexing/__init__.py +58 -0
  114. never4ga/indexing/chunks.py +197 -0
  115. never4ga/indexing/markdown.py +337 -0
  116. never4ga/indexing/projection.py +408 -0
  117. never4ga/layout/__init__.py +79 -0
  118. never4ga/layout/decision_numbers.py +72 -0
  119. never4ga/layout/structure.py +418 -0
  120. never4ga/mcp/__init__.py +25 -0
  121. never4ga/mcp/__main__.py +69 -0
  122. never4ga/mcp/jsonrpc.py +134 -0
  123. never4ga/mcp/server.py +169 -0
  124. never4ga/mcp/toolbox.py +879 -0
  125. never4ga/mcp/tools.py +62 -0
  126. never4ga/platform_paths.py +174 -0
  127. never4ga/ports/__init__.py +11 -0
  128. never4ga/ports/agent_adapter.py +37 -0
  129. never4ga/ports/client_files.py +49 -0
  130. never4ga/ports/client_registry.py +45 -0
  131. never4ga/ports/context_signal_provider.py +35 -0
  132. never4ga/ports/deployments.py +35 -0
  133. never4ga/ports/document_store.py +193 -0
  134. never4ga/ports/embedding_provider.py +81 -0
  135. never4ga/ports/extension_registry.py +48 -0
  136. never4ga/ports/graph_index.py +118 -0
  137. never4ga/ports/index_state.py +147 -0
  138. never4ga/ports/maintenance_findings.py +118 -0
  139. never4ga/ports/memory_augmentor.py +39 -0
  140. never4ga/ports/metadata_index.py +79 -0
  141. never4ga/ports/repository_locator.py +134 -0
  142. never4ga/ports/secret_store.py +46 -0
  143. never4ga/ports/service_manager.py +83 -0
  144. never4ga/ports/session_store.py +131 -0
  145. never4ga/ports/temporal_graph.py +37 -0
  146. never4ga/ports/text_index.py +120 -0
  147. never4ga/ports/tracker_cache.py +113 -0
  148. never4ga/ports/vault_files.py +73 -0
  149. never4ga/ports/vector_index.py +93 -0
  150. never4ga/ports/work_management.py +428 -0
  151. never4ga/ports/workspace_mappings.py +46 -0
  152. never4ga/rendering.py +438 -0
  153. never4ga/schema/__init__.py +89 -0
  154. never4ga/schema/freshness.py +58 -0
  155. never4ga/schema/registries.py +73 -0
  156. never4ga/schema/types.py +515 -0
  157. never4ga/schema/validation.py +813 -0
  158. never4ga/schema/vocabulary.py +227 -0
  159. never4ga/service/__init__.py +33 -0
  160. never4ga/service/runtime.py +579 -0
  161. never4ga/service/server.py +288 -0
  162. never4ga/service/watcher.py +175 -0
  163. never4ga/service_client.py +268 -0
  164. never4ga/services/__init__.py +100 -0
  165. never4ga/services/adapters.py +690 -0
  166. never4ga/services/authoring.py +139 -0
  167. never4ga/services/capture.py +88 -0
  168. never4ga/services/connections.py +209 -0
  169. never4ga/services/context.py +208 -0
  170. never4ga/services/creation.py +1425 -0
  171. never4ga/services/credentials.py +59 -0
  172. never4ga/services/dashboard.py +421 -0
  173. never4ga/services/doctor.py +2026 -0
  174. never4ga/services/inbox.py +347 -0
  175. never4ga/services/indexing.py +932 -0
  176. never4ga/services/maintenance.py +281 -0
  177. never4ga/services/mcp_registration.py +161 -0
  178. never4ga/services/navigation.py +460 -0
  179. never4ga/services/repair.py +225 -0
  180. never4ga/services/repository_pointers.py +455 -0
  181. never4ga/services/scaffold.py +1818 -0
  182. never4ga/services/scheduling.py +150 -0
  183. never4ga/services/search.py +457 -0
  184. never4ga/services/session.py +174 -0
  185. never4ga/services/sessions.py +170 -0
  186. never4ga/services/skill_authoring.py +127 -0
  187. never4ga/services/skill_library.py +336 -0
  188. never4ga/services/startup_command.py +56 -0
  189. never4ga/services/trackers.py +255 -0
  190. never4ga/services/validation.py +97 -0
  191. never4ga/services/vault.py +385 -0
  192. never4ga/services/vendoring.py +446 -0
  193. never4ga/services/work_linking.py +268 -0
  194. never4ga/services/work_reading.py +299 -0
  195. never4ga/services/work_recording.py +162 -0
  196. never4ga/services/work_signals.py +367 -0
  197. never4ga/services/work_writing.py +318 -0
  198. never4ga/services/workspaces.py +406 -0
  199. never4ga/services/wrap.py +589 -0
  200. never4ga-0.1.0.dist-info/METADATA +204 -0
  201. never4ga-0.1.0.dist-info/RECORD +204 -0
  202. never4ga-0.1.0.dist-info/WHEEL +4 -0
  203. never4ga-0.1.0.dist-info/entry_points.txt +3 -0
  204. never4ga-0.1.0.dist-info/licenses/LICENSE +73 -0
never4ga/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Never4gA -- Markdown-first, local-first personal knowledge and context system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ __all__ = ["__version__"]
8
+
9
+ #: The installed package's version. `pyproject.toml` is the one place it is
10
+ #: written; everything that reports a version reads it from here. A source
11
+ #: tree imported without being installed has no metadata to read.
12
+ try:
13
+ __version__ = version("never4ga")
14
+ except PackageNotFoundError:
15
+ __version__ = "0+unknown"
@@ -0,0 +1,6 @@
1
+ """Adapters.
2
+
3
+ Provider-specific and backend-specific code lives here so that core concepts do
4
+ not depend on provider APIs (core/05 section 5). Real backends sit beside
5
+ in-memory fakes and null implementations of the same ports.
6
+ """
@@ -0,0 +1,7 @@
1
+ """Adapters for the agent clients installed on this machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from never4ga.adapters.clients.mcp_registry import SubprocessClientRegistry
6
+
7
+ __all__ = ["SubprocessClientRegistry"]
@@ -0,0 +1,74 @@
1
+ """Registering an MCP server by asking the client to do it.
2
+
3
+ Every client with a shipped descriptor has an ``mcp add`` verb of its own, and
4
+ Never4gA calls it rather than writing the client's configuration file. The
5
+ clients' file formats differ and are not reliably documented, so writing them
6
+ would mean inventing structure. Calling the client's own verb leaves format
7
+ changes, merge semantics and `core/09`'s "never overwrite an unmanaged MCP
8
+ server" with the client. Nothing is overwritten because nothing here opens a
9
+ file.
10
+
11
+ **An adapter, because it runs a process.** Like `adapters/git` and
12
+ `adapters/systemd`, this is an adapter because a service written against ports
13
+ must not know that the machine has executables on it.
14
+
15
+ **Nothing here decides.** It runs an argv somebody already approved and reports
16
+ what happened. Whether to run one at all is `services/mcp_registration.py`, and
17
+ whether *this* invocation applies is the caller's ``--apply``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import subprocess
23
+ from typing import Final
24
+
25
+ from never4ga.domain.clients import ClientDescriptor
26
+ from never4ga.ports.client_registry import RegistrationOutcome
27
+
28
+ __all__ = ["SubprocessClientRegistry"]
29
+
30
+ #: Long enough for a client to rewrite a config file, short enough that a
31
+ #: hanging client does not hang the caller.
32
+ TIMEOUT: Final = 30.0
33
+
34
+
35
+ class SubprocessClientRegistry:
36
+ """Runs the client's own command, and never raises on its behalf."""
37
+
38
+ def register(
39
+ self, descriptor: ClientDescriptor, command: tuple[str, ...]
40
+ ) -> RegistrationOutcome:
41
+ return _run(descriptor, command)
42
+
43
+
44
+ def _run(descriptor: ClientDescriptor, command: tuple[str, ...]) -> RegistrationOutcome:
45
+ try:
46
+ finished = subprocess.run(
47
+ list(command),
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=TIMEOUT,
51
+ check=False,
52
+ )
53
+ except FileNotFoundError:
54
+ return RegistrationOutcome(
55
+ descriptor.client_id, command, ok=False, detail=f"{command[0]} is not on PATH"
56
+ )
57
+ except subprocess.TimeoutExpired:
58
+ return RegistrationOutcome(
59
+ descriptor.client_id,
60
+ command,
61
+ ok=False,
62
+ detail=f"{command[0]} did not finish within {TIMEOUT:.0f}s",
63
+ )
64
+ if finished.returncode != 0:
65
+ detail = (finished.stderr or finished.stdout or "").strip().splitlines()
66
+ return RegistrationOutcome(
67
+ descriptor.client_id,
68
+ command,
69
+ ok=False,
70
+ detail=detail[-1] if detail else f"exited {finished.returncode}",
71
+ )
72
+ return RegistrationOutcome(
73
+ descriptor.client_id, command, ok=True, detail=(finished.stdout or "").strip()
74
+ )
@@ -0,0 +1,47 @@
1
+ """In-memory fakes.
2
+
3
+ core/06 section 25 Tests A and C require that a fake backend can replace a real
4
+ one without touching higher-level code. These fakes are shipped rather than
5
+ confined to the test tree so that future backend adapters can be diffed against
6
+ a known-good reference implementation of each contract.
7
+ """
8
+
9
+ from never4ga.adapters.fakes.agent_adapter import FakeAgentAdapter
10
+ from never4ga.adapters.fakes.document_store import InMemoryDocumentStore
11
+ from never4ga.adapters.fakes.extension_registry import InMemoryExtensionRegistry
12
+ from never4ga.adapters.fakes.graph_index import InMemoryGraphIndex
13
+ from never4ga.adapters.fakes.index_state import InMemoryIndexState
14
+ from never4ga.adapters.fakes.maintenance_findings import InMemoryMaintenanceFindings
15
+ from never4ga.adapters.fakes.memory import FakeMemoryAugmentor
16
+ from never4ga.adapters.fakes.metadata_index import InMemoryMetadataIndex
17
+ from never4ga.adapters.fakes.repository_locator import FakeRepositoryLocator
18
+ from never4ga.adapters.fakes.secret_store import InMemorySecretStore
19
+ from never4ga.adapters.fakes.service_manager import FakeServiceManager
20
+ from never4ga.adapters.fakes.text_index import InMemoryTextIndex
21
+ from never4ga.adapters.fakes.tracker_cache import InMemoryTrackerCache
22
+ from never4ga.adapters.fakes.vault_files import InMemoryVaultFileStore
23
+ from never4ga.adapters.fakes.work_management import (
24
+ FakeWorkManagementProvider,
25
+ FakeWorkManagementWriter,
26
+ )
27
+ from never4ga.adapters.fakes.workspace_mappings import InMemoryWorkspaceMappingStore
28
+
29
+ __all__ = [
30
+ "FakeAgentAdapter",
31
+ "FakeMemoryAugmentor",
32
+ "FakeRepositoryLocator",
33
+ "FakeServiceManager",
34
+ "FakeWorkManagementProvider",
35
+ "FakeWorkManagementWriter",
36
+ "InMemoryDocumentStore",
37
+ "InMemoryExtensionRegistry",
38
+ "InMemoryGraphIndex",
39
+ "InMemoryIndexState",
40
+ "InMemoryMaintenanceFindings",
41
+ "InMemoryMetadataIndex",
42
+ "InMemorySecretStore",
43
+ "InMemoryTextIndex",
44
+ "InMemoryTrackerCache",
45
+ "InMemoryVaultFileStore",
46
+ "InMemoryWorkspaceMappingStore",
47
+ ]
@@ -0,0 +1,38 @@
1
+ """Fake AgentAdapter -- reports a fixed set of discovered capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from never4ga.domain.extensions import DiscoveredCapability, ExtensionClass
8
+ from never4ga.ports.agent_adapter import AdapterDescription
9
+
10
+ __all__ = ["FakeAgentAdapter"]
11
+
12
+
13
+ class FakeAgentAdapter:
14
+ def __init__(
15
+ self,
16
+ client_id: str = "fake_client",
17
+ discovered: Sequence[DiscoveredCapability] = (),
18
+ supported_classes: frozenset[ExtensionClass] | None = None,
19
+ ) -> None:
20
+ self._client_id = client_id
21
+ self._discovered = tuple(discovered)
22
+ self._supported = supported_classes or frozenset(
23
+ {ExtensionClass.SKILL, ExtensionClass.MCP_SERVER}
24
+ )
25
+
26
+ @property
27
+ def client_id(self) -> str:
28
+ return self._client_id
29
+
30
+ def describe(self) -> AdapterDescription:
31
+ return AdapterDescription(
32
+ client_id=self._client_id,
33
+ display_name=self._client_id.replace("_", " ").title(),
34
+ supported_classes=self._supported,
35
+ )
36
+
37
+ def discover(self) -> Sequence[DiscoveredCapability]:
38
+ return self._discovered
@@ -0,0 +1,80 @@
1
+ """In-memory DocumentStore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Collection, Iterator
6
+
7
+ from never4ga.domain.document import ForeignNote, StoredDocument, UntrackedMarkdown, VaultPath
8
+ from never4ga.domain.identity import ConceptId
9
+ from never4ga.layout import is_foreign_note
10
+
11
+ __all__ = ["InMemoryDocumentStore"]
12
+
13
+
14
+ class InMemoryDocumentStore:
15
+ """A DocumentStore backed by dictionaries.
16
+
17
+ Used by contract tests and by higher-level tests that need canonical
18
+ documents without a temporary vault on disk.
19
+ """
20
+
21
+ def __init__(self) -> None:
22
+ self._by_id: dict[ConceptId, StoredDocument] = {}
23
+ self._id_by_path: dict[VaultPath, ConceptId] = {}
24
+ self._untracked: dict[VaultPath, UntrackedMarkdown] = {}
25
+
26
+ def get(self, concept_id: ConceptId) -> StoredDocument | None:
27
+ return self._by_id.get(concept_id)
28
+
29
+ def get_by_path(self, path: VaultPath) -> StoredDocument | None:
30
+ concept_id = self._id_by_path.get(path)
31
+ return self._by_id.get(concept_id) if concept_id is not None else None
32
+
33
+ def put(self, document: StoredDocument) -> None:
34
+ occupant = self._id_by_path.get(document.path)
35
+ if occupant is not None and occupant != document.concept_id:
36
+ raise ValueError(
37
+ f"path {document.path} is already occupied by {occupant}; "
38
+ "two documents cannot share one location"
39
+ )
40
+ previous = self._by_id.get(document.concept_id)
41
+ if previous is not None and previous.path != document.path:
42
+ # A move: identity is preserved, the old location is vacated.
43
+ self._id_by_path.pop(previous.path, None)
44
+ self._by_id[document.concept_id] = document
45
+ self._id_by_path[document.path] = document.concept_id
46
+ # Writing a concept where prose sat is adoption completing.
47
+ self._untracked.pop(document.path, None)
48
+
49
+ def delete(self, concept_id: ConceptId) -> None:
50
+ document = self._by_id.pop(concept_id, None)
51
+ if document is not None:
52
+ self._id_by_path.pop(document.path, None)
53
+
54
+ def iter_documents(self) -> Iterator[StoredDocument]:
55
+ return iter(list(self._by_id.values()))
56
+
57
+ def get_untracked(self, path: VaultPath) -> UntrackedMarkdown | None:
58
+ return self._untracked.get(path)
59
+
60
+ def place_untracked(self, untracked: UntrackedMarkdown) -> None:
61
+ """Seed hand-written Markdown, the way a person's editor would."""
62
+ self._untracked[untracked.path] = untracked
63
+
64
+ def remove_untracked(self, path: VaultPath) -> None:
65
+ """Delete hand-written Markdown, the way a person would."""
66
+ self._untracked.pop(path, None)
67
+
68
+ def iter_foreign_notes(self, directories: Collection[str]) -> Iterator[ForeignNote]:
69
+ """The seeded hand-written Markdown that sits in foreign material."""
70
+ for path in sorted(self._untracked):
71
+ if is_foreign_note(path, directories):
72
+ note = self.get_foreign_note(path)
73
+ assert note is not None
74
+ yield note
75
+
76
+ def get_foreign_note(self, path: VaultPath) -> ForeignNote | None:
77
+ untracked = self._untracked.get(path)
78
+ if untracked is None:
79
+ return None
80
+ return ForeignNote(path=path, frontmatter=untracked.frontmatter, body=untracked.body)
@@ -0,0 +1,57 @@
1
+ """In-memory ExtensionRegistry.
2
+
3
+ Storage and filtering only. The planning rules live in
4
+ :func:`never4ga.domain.extensions.plan_capability_sync`: rules kept in a fake
5
+ would be a property of the test double rather than of the system, and a service
6
+ may not import an adapter to reach them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+
13
+ from never4ga.domain.extensions import (
14
+ DeploymentMode,
15
+ DiscoveredCapability,
16
+ ExtensionClass,
17
+ ExtensionRecord,
18
+ SyncPlan,
19
+ plan_capability_sync,
20
+ )
21
+
22
+ __all__ = ["InMemoryExtensionRegistry"]
23
+
24
+
25
+ class InMemoryExtensionRegistry:
26
+ def __init__(self) -> None:
27
+ self._records: dict[str, ExtensionRecord] = {}
28
+
29
+ def register(self, record: ExtensionRecord) -> None:
30
+ self._records[record.capability_id] = record
31
+
32
+ def get(self, capability_id: str) -> ExtensionRecord | None:
33
+ return self._records.get(capability_id)
34
+
35
+ def list_records(
36
+ self,
37
+ *,
38
+ extension_class: ExtensionClass | None = None,
39
+ deployment_mode: DeploymentMode | None = None,
40
+ client_id: str | None = None,
41
+ ) -> Sequence[ExtensionRecord]:
42
+ records = [
43
+ record
44
+ for record in self._records.values()
45
+ if (extension_class is None or record.extension_class is extension_class)
46
+ and (deployment_mode is None or record.deployment_mode is deployment_mode)
47
+ and (client_id is None or record.is_enabled_for(client_id))
48
+ ]
49
+ records.sort(key=lambda record: record.capability_id)
50
+ return tuple(records)
51
+
52
+ def plan_sync(
53
+ self,
54
+ client_id: str,
55
+ discovered: Sequence[DiscoveredCapability],
56
+ ) -> SyncPlan:
57
+ return plan_capability_sync(client_id, self.list_records(), discovered)
@@ -0,0 +1,112 @@
1
+ """In-memory GraphIndex.
2
+
3
+ Adjacency lists plus a breadth-first bounded walk -- structurally the same shape
4
+ as the SQLite recursive-CTE traversal of core/06 section 14, which is why this
5
+ fake can stand in for ``SQLiteGraphIndex``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+ from collections.abc import Iterator, Sequence
12
+
13
+ from never4ga.domain.capabilities import GraphIndexCapability
14
+ from never4ga.domain.identity import ConceptId
15
+ from never4ga.errors import CapabilityNotSupportedError
16
+ from never4ga.ports.graph_index import Direction, GraphEdge, GraphNeighbor, GraphPathStep
17
+
18
+ __all__ = ["InMemoryGraphIndex"]
19
+
20
+
21
+ class InMemoryGraphIndex:
22
+ def __init__(self) -> None:
23
+ self._edges: list[GraphEdge] = []
24
+
25
+ @property
26
+ def capabilities(self) -> frozenset[GraphIndexCapability]:
27
+ # Deliberately a subset: core/06 section 13 expects the first backend to
28
+ # implement only part of the contract and to say so.
29
+ return frozenset({GraphIndexCapability.NEIGHBORS, GraphIndexCapability.RECURSIVE_TRAVERSAL})
30
+
31
+ def add_edge(self, edge: GraphEdge) -> None:
32
+ if edge not in self._edges:
33
+ self._edges.append(edge)
34
+
35
+ def replace_outgoing_edges(self, source: ConceptId, edges: Sequence[GraphEdge]) -> None:
36
+ self._edges = [edge for edge in self._edges if edge.source != source]
37
+ for edge in edges:
38
+ self.add_edge(edge)
39
+
40
+ def remove_document(self, concept_id: ConceptId) -> None:
41
+ self._edges = [
42
+ edge for edge in self._edges if edge.source != concept_id and edge.target != concept_id
43
+ ]
44
+
45
+ def _incident(
46
+ self,
47
+ concept_id: ConceptId,
48
+ direction: Direction,
49
+ relation_types: tuple[str, ...],
50
+ ) -> Iterator[GraphNeighbor]:
51
+ for edge in self._edges:
52
+ if relation_types and edge.relation_type not in relation_types:
53
+ continue
54
+ if edge.source == concept_id and direction in (Direction.OUTGOING, Direction.BOTH):
55
+ yield GraphNeighbor(edge.target, edge.relation_type, Direction.OUTGOING)
56
+ if edge.target == concept_id and direction in (Direction.INCOMING, Direction.BOTH):
57
+ yield GraphNeighbor(edge.source, edge.relation_type, Direction.INCOMING)
58
+
59
+ def neighbors(
60
+ self,
61
+ concept_id: ConceptId,
62
+ *,
63
+ direction: Direction = Direction.BOTH,
64
+ relation_types: tuple[str, ...] = (),
65
+ limit: int | None = None,
66
+ ) -> Sequence[GraphNeighbor]:
67
+ found = list(self._incident(concept_id, direction, relation_types))
68
+ return tuple(found[:limit] if limit is not None else found)
69
+
70
+ def traverse(
71
+ self,
72
+ start: ConceptId,
73
+ *,
74
+ max_depth: int,
75
+ direction: Direction = Direction.BOTH,
76
+ relation_types: tuple[str, ...] = (),
77
+ ) -> Sequence[GraphPathStep]:
78
+ if max_depth < 1:
79
+ raise ValueError(f"max_depth must be at least 1, got {max_depth}")
80
+ seen = {start}
81
+ steps: list[GraphPathStep] = []
82
+ frontier: deque[tuple[ConceptId, int]] = deque([(start, 0)])
83
+ while frontier:
84
+ node, depth = frontier.popleft()
85
+ if depth >= max_depth:
86
+ continue
87
+ for neighbor in self._incident(node, direction, relation_types):
88
+ if neighbor.concept_id in seen:
89
+ continue
90
+ seen.add(neighbor.concept_id)
91
+ steps.append(
92
+ GraphPathStep(
93
+ concept_id=neighbor.concept_id,
94
+ depth=depth + 1,
95
+ relation_type=neighbor.relation_type,
96
+ via=node,
97
+ )
98
+ )
99
+ frontier.append((neighbor.concept_id, depth + 1))
100
+ return tuple(steps)
101
+
102
+ def shortest_path(
103
+ self,
104
+ start: ConceptId,
105
+ end: ConceptId,
106
+ *,
107
+ relation_types: tuple[str, ...] = (),
108
+ ) -> Sequence[GraphPathStep]:
109
+ raise CapabilityNotSupportedError("InMemoryGraphIndex does not support 'shortest_path'")
110
+
111
+ def clear(self) -> None:
112
+ self._edges.clear()
@@ -0,0 +1,56 @@
1
+ """In-memory IndexState.
2
+
3
+ Lets a service test run the whole indexing pipeline with no database at all,
4
+ which is what keeps the pipeline honest about depending on ports rather than on
5
+ SQLite (core/06 section 25 test A).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from never4ga.domain.document import VaultPath
13
+ from never4ga.domain.identity import ConceptId
14
+ from never4ga.ports.index_state import IndexedDocument, IndexedPath, LinkRecord
15
+
16
+ __all__ = ["InMemoryIndexState"]
17
+
18
+
19
+ class InMemoryIndexState:
20
+ def __init__(self) -> None:
21
+ self._documents: dict[ConceptId, IndexedDocument] = {}
22
+ self._links: dict[ConceptId, tuple[LinkRecord, ...]] = {}
23
+ self._paths: dict[VaultPath, IndexedPath] = {}
24
+
25
+ def record(self, document: IndexedDocument) -> None:
26
+ self._documents[document.concept_id] = document
27
+
28
+ def forget(self, concept_id: ConceptId) -> None:
29
+ self._documents.pop(concept_id, None)
30
+ self._links.pop(concept_id, None)
31
+
32
+ def get(self, concept_id: ConceptId) -> IndexedDocument | None:
33
+ return self._documents.get(concept_id)
34
+
35
+ def indexed_documents(self) -> Sequence[IndexedDocument]:
36
+ return tuple(self._documents[key] for key in sorted(self._documents))
37
+
38
+ def replace_links(self, source: ConceptId, links: Sequence[LinkRecord]) -> None:
39
+ self._links[source] = tuple(links)
40
+
41
+ def links(self) -> Sequence[LinkRecord]:
42
+ return tuple(link for key in sorted(self._links) for link in self._links[key])
43
+
44
+ def record_path(self, record: IndexedPath) -> None:
45
+ self._paths[record.path] = record
46
+
47
+ def forget_path(self, path: VaultPath) -> None:
48
+ self._paths.pop(path, None)
49
+
50
+ def indexed_paths(self) -> Sequence[IndexedPath]:
51
+ return tuple(self._paths[key] for key in sorted(self._paths))
52
+
53
+ def clear(self) -> None:
54
+ self._documents.clear()
55
+ self._links.clear()
56
+ self._paths.clear()
@@ -0,0 +1,44 @@
1
+ """An in-memory MaintenanceFindings, for tests and for a vault with no database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import replace
7
+ from datetime import datetime
8
+
9
+ from never4ga.ports.maintenance_findings import MaintenanceFinding
10
+
11
+ __all__ = ["InMemoryMaintenanceFindings"]
12
+
13
+
14
+ class InMemoryMaintenanceFindings:
15
+ """A MaintenanceFindings over a list.
16
+
17
+ Closed findings are kept rather than dropped, so the store can answer "has
18
+ this been resolved before" the same way the SQLite one does. Nothing reads
19
+ that yet; keeping them costs a list entry and losing them would make the two
20
+ implementations disagree about a question somebody will eventually ask.
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ self._findings: list[MaintenanceFinding] = []
25
+
26
+ def open_findings(self) -> Sequence[MaintenanceFinding]:
27
+ return sorted(
28
+ (f for f in self._findings if f.is_open),
29
+ key=lambda f: (f.detected_at, f.fingerprint),
30
+ )
31
+
32
+ def record(self, finding: MaintenanceFinding) -> None:
33
+ if any(f.fingerprint == finding.fingerprint and f.is_open for f in self._findings):
34
+ return
35
+ self._findings.append(replace(finding, resolved_at=None))
36
+
37
+ def resolve(self, fingerprint: str, at: datetime) -> None:
38
+ self._findings = [
39
+ replace(f, resolved_at=at) if f.fingerprint == fingerprint and f.is_open else f
40
+ for f in self._findings
41
+ ]
42
+
43
+ def clear(self) -> None:
44
+ self._findings.clear()
@@ -0,0 +1,58 @@
1
+ """In-memory MemoryAugmentor.
2
+
3
+ It exists so the external-memory contract runs against a provider that actually
4
+ returns candidates -- a null provider cannot demonstrate that provider identity
5
+ stays separate from canonical identity (core/08 sections 6 and 15).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import itertools
11
+ from collections.abc import Sequence
12
+
13
+ from never4ga.domain.capabilities import MemoryCapability
14
+ from never4ga.domain.identity import ExternalId
15
+ from never4ga.domain.memory import IngestReceipt, MemoryCandidate, MemoryEpisode, MemoryQuery
16
+ from never4ga.ports.work_management import ProviderHealth
17
+
18
+ __all__ = ["FakeMemoryAugmentor"]
19
+
20
+
21
+ class FakeMemoryAugmentor:
22
+ provider_id = "fake-memory"
23
+
24
+ def __init__(self, candidates: Sequence[MemoryCandidate] = ()) -> None:
25
+ self._candidates = list(candidates)
26
+ self._episodes: list[MemoryEpisode] = []
27
+ self._counter = itertools.count(1)
28
+
29
+ @property
30
+ def capabilities(self) -> frozenset[MemoryCapability]:
31
+ return frozenset({MemoryCapability.SEARCH, MemoryCapability.EPISODIC_INGEST})
32
+
33
+ @property
34
+ def ingested(self) -> Sequence[MemoryEpisode]:
35
+ return tuple(self._episodes)
36
+
37
+ def search(self, query: MemoryQuery) -> Sequence[MemoryCandidate]:
38
+ matches = [
39
+ candidate
40
+ for candidate in self._candidates
41
+ if not query.terms
42
+ or any(term.casefold() in candidate.text.casefold() for term in query.terms)
43
+ ]
44
+ if query.workspace_id is not None:
45
+ # A provider that cannot scope by workspace must not pretend it can;
46
+ # this one stores no workspace, so scoping is the caller's job.
47
+ pass
48
+ if query.limit is not None:
49
+ matches = matches[: query.limit]
50
+ return tuple(matches)
51
+
52
+ def ingest_episode(self, episode: MemoryEpisode) -> IngestReceipt:
53
+ self._episodes.append(episode)
54
+ external_id = ExternalId(provider=self.provider_id, value=f"ep-{next(self._counter)}")
55
+ return IngestReceipt(provider_id=self.provider_id, accepted=True, external_id=external_id)
56
+
57
+ def health(self) -> ProviderHealth:
58
+ return ProviderHealth(available=True)
@@ -0,0 +1,59 @@
1
+ """In-memory MetadataIndex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from never4ga.domain.capabilities import MetadataIndexCapability
8
+ from never4ga.domain.identity import ConceptId
9
+ from never4ga.ports.metadata_index import MetadataQuery, MetadataRecord
10
+
11
+ __all__ = ["InMemoryMetadataIndex"]
12
+
13
+
14
+ class InMemoryMetadataIndex:
15
+ """A MetadataIndex backed by a dictionary, evaluating filters in Python."""
16
+
17
+ def __init__(self) -> None:
18
+ self._records: dict[ConceptId, MetadataRecord] = {}
19
+
20
+ @property
21
+ def capabilities(self) -> frozenset[MetadataIndexCapability]:
22
+ return frozenset({MetadataIndexCapability.SET_FILTERS})
23
+
24
+ def upsert(self, record: MetadataRecord) -> None:
25
+ self._records[record.concept_id] = record
26
+
27
+ def remove(self, concept_id: ConceptId) -> None:
28
+ self._records.pop(concept_id, None)
29
+
30
+ def get(self, concept_id: ConceptId) -> MetadataRecord | None:
31
+ return self._records.get(concept_id)
32
+
33
+ def query(self, query: MetadataQuery) -> Sequence[MetadataRecord]:
34
+ matches = [record for record in self._records.values() if _matches(record, query)]
35
+ # Deterministic ordering: identity is time-ordered, so this is insertion
36
+ # order in practice and stable across rebuilds.
37
+ matches.sort(key=lambda record: record.concept_id)
38
+ if query.limit is not None:
39
+ matches = matches[: query.limit]
40
+ return tuple(matches)
41
+
42
+ def clear(self) -> None:
43
+ self._records.clear()
44
+
45
+
46
+ def _matches(record: MetadataRecord, query: MetadataQuery) -> bool:
47
+ scalar_filters = (
48
+ (query.types, record.concept_type),
49
+ (query.lifecycles, record.lifecycle),
50
+ (query.statuses, record.status),
51
+ (query.authorities, record.authority),
52
+ )
53
+ for allowed, value in scalar_filters:
54
+ if allowed and value not in allowed:
55
+ return False
56
+ if query.workspace_ids and record.workspace_id not in query.workspace_ids:
57
+ return False
58
+ set_filters = ((query.tags, record.tags), (query.domains, record.domains))
59
+ return all(not wanted or set(wanted) & set(held) for wanted, held in set_filters)