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.
- never4ga/__init__.py +15 -0
- never4ga/adapters/__init__.py +6 -0
- never4ga/adapters/clients/__init__.py +7 -0
- never4ga/adapters/clients/mcp_registry.py +74 -0
- never4ga/adapters/fakes/__init__.py +47 -0
- never4ga/adapters/fakes/agent_adapter.py +38 -0
- never4ga/adapters/fakes/document_store.py +80 -0
- never4ga/adapters/fakes/extension_registry.py +57 -0
- never4ga/adapters/fakes/graph_index.py +112 -0
- never4ga/adapters/fakes/index_state.py +56 -0
- never4ga/adapters/fakes/maintenance_findings.py +44 -0
- never4ga/adapters/fakes/memory.py +58 -0
- never4ga/adapters/fakes/metadata_index.py +59 -0
- never4ga/adapters/fakes/repository_locator.py +104 -0
- never4ga/adapters/fakes/secret_store.py +37 -0
- never4ga/adapters/fakes/service_manager.py +39 -0
- never4ga/adapters/fakes/session_store.py +102 -0
- never4ga/adapters/fakes/text_index.py +115 -0
- never4ga/adapters/fakes/tracker_cache.py +47 -0
- never4ga/adapters/fakes/vault_files.py +49 -0
- never4ga/adapters/fakes/work_management.py +278 -0
- never4ga/adapters/fakes/workspace_mappings.py +24 -0
- never4ga/adapters/filesystem/__init__.py +54 -0
- never4ga/adapters/filesystem/client_files.py +79 -0
- never4ga/adapters/filesystem/deployments.py +153 -0
- never4ga/adapters/filesystem/document_store.py +541 -0
- never4ga/adapters/filesystem/extension_registry.py +210 -0
- never4ga/adapters/filesystem/frontmatter.py +226 -0
- never4ga/adapters/filesystem/repository_locator.py +142 -0
- never4ga/adapters/filesystem/secret_store.py +165 -0
- never4ga/adapters/filesystem/vault_files.py +100 -0
- never4ga/adapters/filesystem/workspace_mappings.py +233 -0
- never4ga/adapters/git/__init__.py +14 -0
- never4ga/adapters/git/signals.py +158 -0
- never4ga/adapters/null/__init__.py +20 -0
- never4ga/adapters/null/memory.py +62 -0
- never4ga/adapters/null/service_manager.py +40 -0
- never4ga/adapters/null/workspace_mappings.py +27 -0
- never4ga/adapters/openproject/__init__.py +33 -0
- never4ga/adapters/openproject/api.py +341 -0
- never4ga/adapters/openproject/normalise.py +211 -0
- never4ga/adapters/openproject/provider.py +453 -0
- never4ga/adapters/openproject/write.py +207 -0
- never4ga/adapters/openproject/writer.py +531 -0
- never4ga/adapters/sqlite/__init__.py +40 -0
- never4ga/adapters/sqlite/connection.py +445 -0
- never4ga/adapters/sqlite/graph_index.py +192 -0
- never4ga/adapters/sqlite/index_state.py +211 -0
- never4ga/adapters/sqlite/maintenance_findings.py +96 -0
- never4ga/adapters/sqlite/metadata_index.py +236 -0
- never4ga/adapters/sqlite/session_store.py +292 -0
- never4ga/adapters/sqlite/sessions.py +192 -0
- never4ga/adapters/sqlite/text_index.py +322 -0
- never4ga/adapters/sqlite/tracker_cache.py +187 -0
- never4ga/adapters/sqlite/trackers.py +72 -0
- never4ga/adapters/systemd/__init__.py +22 -0
- never4ga/adapters/systemd/service_manager.py +183 -0
- never4ga/adapters/work_management.py +103 -0
- never4ga/api/__init__.py +13 -0
- never4ga/api/app.py +1021 -0
- never4ga/api/errors.py +290 -0
- never4ga/api/models.py +752 -0
- never4ga/build.py +118 -0
- never4ga/cli/__init__.py +4225 -0
- never4ga/cli/__main__.py +10 -0
- never4ga/cli/connections.py +150 -0
- never4ga/cli/output.py +75 -0
- never4ga/cli/work.py +108 -0
- never4ga/client_descriptors.py +146 -0
- never4ga/clients/antigravity-cli.toml +32 -0
- never4ga/clients/claude-code.toml +35 -0
- never4ga/clients/codex-cli.toml +27 -0
- never4ga/composition.py +493 -0
- never4ga/config.py +293 -0
- never4ga/context/__init__.py +7 -0
- never4ga/context/assembler.py +125 -0
- never4ga/context/authority.py +110 -0
- never4ga/context/budget.py +304 -0
- never4ga/context/deep.py +108 -0
- never4ga/context/focused.py +323 -0
- never4ga/context/foreign.py +55 -0
- never4ga/context/freshness.py +37 -0
- never4ga/context/fusion.py +123 -0
- never4ga/context/inbox_signals.py +148 -0
- never4ga/context/required.py +71 -0
- never4ga/context/scope.py +149 -0
- never4ga/context/session_signals.py +104 -0
- never4ga/context/signals.py +39 -0
- never4ga/context/structural.py +652 -0
- never4ga/domain/__init__.py +5 -0
- never4ga/domain/capabilities.py +120 -0
- never4ga/domain/chunk.py +100 -0
- never4ga/domain/clients.py +174 -0
- never4ga/domain/configuration.py +30 -0
- never4ga/domain/connections.py +86 -0
- never4ga/domain/context.py +331 -0
- never4ga/domain/deployments.py +85 -0
- never4ga/domain/document.py +183 -0
- never4ga/domain/extensions.py +354 -0
- never4ga/domain/identity.py +172 -0
- never4ga/domain/memory.py +92 -0
- never4ga/domain/provenance.py +119 -0
- never4ga/domain/relations.py +82 -0
- never4ga/domain/retrieval.py +65 -0
- never4ga/domain/scope.py +61 -0
- never4ga/domain/sessions.py +243 -0
- never4ga/domain/signals.py +77 -0
- never4ga/domain/text_hazards.py +150 -0
- never4ga/domain/vendoring.py +184 -0
- never4ga/domain/work_policy.py +147 -0
- never4ga/errors/__init__.py +278 -0
- never4ga/errors/reporting.py +37 -0
- never4ga/indexing/__init__.py +58 -0
- never4ga/indexing/chunks.py +197 -0
- never4ga/indexing/markdown.py +337 -0
- never4ga/indexing/projection.py +408 -0
- never4ga/layout/__init__.py +79 -0
- never4ga/layout/decision_numbers.py +72 -0
- never4ga/layout/structure.py +418 -0
- never4ga/mcp/__init__.py +25 -0
- never4ga/mcp/__main__.py +69 -0
- never4ga/mcp/jsonrpc.py +134 -0
- never4ga/mcp/server.py +169 -0
- never4ga/mcp/toolbox.py +879 -0
- never4ga/mcp/tools.py +62 -0
- never4ga/platform_paths.py +174 -0
- never4ga/ports/__init__.py +11 -0
- never4ga/ports/agent_adapter.py +37 -0
- never4ga/ports/client_files.py +49 -0
- never4ga/ports/client_registry.py +45 -0
- never4ga/ports/context_signal_provider.py +35 -0
- never4ga/ports/deployments.py +35 -0
- never4ga/ports/document_store.py +193 -0
- never4ga/ports/embedding_provider.py +81 -0
- never4ga/ports/extension_registry.py +48 -0
- never4ga/ports/graph_index.py +118 -0
- never4ga/ports/index_state.py +147 -0
- never4ga/ports/maintenance_findings.py +118 -0
- never4ga/ports/memory_augmentor.py +39 -0
- never4ga/ports/metadata_index.py +79 -0
- never4ga/ports/repository_locator.py +134 -0
- never4ga/ports/secret_store.py +46 -0
- never4ga/ports/service_manager.py +83 -0
- never4ga/ports/session_store.py +131 -0
- never4ga/ports/temporal_graph.py +37 -0
- never4ga/ports/text_index.py +120 -0
- never4ga/ports/tracker_cache.py +113 -0
- never4ga/ports/vault_files.py +73 -0
- never4ga/ports/vector_index.py +93 -0
- never4ga/ports/work_management.py +428 -0
- never4ga/ports/workspace_mappings.py +46 -0
- never4ga/rendering.py +438 -0
- never4ga/schema/__init__.py +89 -0
- never4ga/schema/freshness.py +58 -0
- never4ga/schema/registries.py +73 -0
- never4ga/schema/types.py +515 -0
- never4ga/schema/validation.py +813 -0
- never4ga/schema/vocabulary.py +227 -0
- never4ga/service/__init__.py +33 -0
- never4ga/service/runtime.py +579 -0
- never4ga/service/server.py +288 -0
- never4ga/service/watcher.py +175 -0
- never4ga/service_client.py +268 -0
- never4ga/services/__init__.py +100 -0
- never4ga/services/adapters.py +690 -0
- never4ga/services/authoring.py +139 -0
- never4ga/services/capture.py +88 -0
- never4ga/services/connections.py +209 -0
- never4ga/services/context.py +208 -0
- never4ga/services/creation.py +1425 -0
- never4ga/services/credentials.py +59 -0
- never4ga/services/dashboard.py +421 -0
- never4ga/services/doctor.py +2026 -0
- never4ga/services/inbox.py +347 -0
- never4ga/services/indexing.py +932 -0
- never4ga/services/maintenance.py +281 -0
- never4ga/services/mcp_registration.py +161 -0
- never4ga/services/navigation.py +460 -0
- never4ga/services/repair.py +225 -0
- never4ga/services/repository_pointers.py +455 -0
- never4ga/services/scaffold.py +1818 -0
- never4ga/services/scheduling.py +150 -0
- never4ga/services/search.py +457 -0
- never4ga/services/session.py +174 -0
- never4ga/services/sessions.py +170 -0
- never4ga/services/skill_authoring.py +127 -0
- never4ga/services/skill_library.py +336 -0
- never4ga/services/startup_command.py +56 -0
- never4ga/services/trackers.py +255 -0
- never4ga/services/validation.py +97 -0
- never4ga/services/vault.py +385 -0
- never4ga/services/vendoring.py +446 -0
- never4ga/services/work_linking.py +268 -0
- never4ga/services/work_reading.py +299 -0
- never4ga/services/work_recording.py +162 -0
- never4ga/services/work_signals.py +367 -0
- never4ga/services/work_writing.py +318 -0
- never4ga/services/workspaces.py +406 -0
- never4ga/services/wrap.py +589 -0
- never4ga-0.1.0.dist-info/METADATA +204 -0
- never4ga-0.1.0.dist-info/RECORD +204 -0
- never4ga-0.1.0.dist-info/WHEEL +4 -0
- never4ga-0.1.0.dist-info/entry_points.txt +3 -0
- 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,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)
|