From 084f2e927151fed212eefe3330b7df78c78417c1 Mon Sep 17 00:00:00 2001 From: NISH1001 Date: Tue, 21 Apr 2026 09:40:09 -0500 Subject: [PATCH 1/7] Add barebone artifacts module --- akd_ext/artifacts/_base.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 akd_ext/artifacts/_base.py diff --git a/akd_ext/artifacts/_base.py b/akd_ext/artifacts/_base.py new file mode 100644 index 0000000..aead7b8 --- /dev/null +++ b/akd_ext/artifacts/_base.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class Artifact[T](BaseModel): + path: str = Field(..., description="Slug that identifies the artifact.") + name: str | None = Field(default=None, description="Name of the artifact.") + description: str | None = Field( + default=None, + description="One-line summary; helps an agent decide whether to load this artifact.", + ) + content: T = Field(..., description="The content of the artifact.") + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Backend-specific escape hatch (mime type, commit sha, author, tags, etc.). Could be loaded from frontmatter as well if md file", + ) + created_at: datetime | None = Field( + default=None, + description=( + "When the artifact was first stored. Output-only: populated by the store " + "on read; ignored on write (stores set it according to their backend)." + ), + ) + updated_at: datetime | None = Field( + default=None, + description=( + "When the artifact was last modified. Output-only: populated by the store " + "on read; ignored on write (stores set it according to their backend)." + ), + ) From 068c50339c04229f3242b35530766b11de89cddb Mon Sep 17 00:00:00 2001 From: NISH1001 Date: Tue, 21 Apr 2026 09:58:59 -0500 Subject: [PATCH 2/7] Add ArtifactStore abstract base to akd_ext.artifacts - Holds path-keyed `_artifacts` dict as the in-memory index, inspired by NISH1001/ada but generalized across backends (LocalDir, GitHub, S3, DB) - Abstract methods: read_artifact, write_artifact, load_artifacts - Concrete helpers on top of the cache: refresh, list_artifacts, keys, and mapping dunders (__getitem__, __contains__, __len__, __iter__) - Also export ArtifactStore alongside Artifact in the package __init__ --- akd_ext/artifacts/__init__.py | 3 ++ akd_ext/artifacts/_base.py | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 akd_ext/artifacts/__init__.py diff --git a/akd_ext/artifacts/__init__.py b/akd_ext/artifacts/__init__.py new file mode 100644 index 0000000..16e5007 --- /dev/null +++ b/akd_ext/artifacts/__init__.py @@ -0,0 +1,3 @@ +from ._base import Artifact, ArtifactStore + +__all__ = ["Artifact", "ArtifactStore"] diff --git a/akd_ext/artifacts/_base.py b/akd_ext/artifacts/_base.py index aead7b8..51246ef 100644 --- a/akd_ext/artifacts/_base.py +++ b/akd_ext/artifacts/_base.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractmethod from datetime import datetime from typing import Any @@ -30,3 +31,75 @@ class Artifact[T](BaseModel): "on read; ignored on write (stores set it according to their backend)." ), ) + + +class ArtifactStore[T](ABC): + """ + Abstract storage for artifacts keyed by path. + + The in-memory `self._artifacts` dict is the authoritative index for listing. + Subclasses are responsible for: + - Populating `self._artifacts` (eagerly in __init__ or lazily on read). + - Keeping it in sync on writes. + - Overriding `refresh()` to re-sync from the backend if needed. + + Timestamp fields (`created_at`, `updated_at`) on Artifact are output-only: + stores populate them on read; caller-supplied values on write are ignored. + """ + + def __init__(self, debug: bool = False) -> None: + self.debug = bool(debug) + self._artifacts: dict[str, Artifact[T]] = {} + + @abstractmethod + async def read_artifact(self, path: str) -> Artifact[T]: + """Fetch a single artifact (content included). Raise on miss. + Implementations MAY cache the result into `self._artifacts`.""" + raise NotImplementedError() + + @abstractmethod + async def write_artifact(self, artifact: Artifact[T]) -> Artifact[T]: + """Persist to the backend. Returns the stored artifact with timestamps + populated. Implementations MUST update `self._artifacts` to keep the + cache consistent.""" + raise NotImplementedError() + + @abstractmethod + async def load_artifacts(self) -> None: + """Populate `self._artifacts` from the backend. Called once after + construction (and again by `refresh()`). Functional counterpart of + ada's `@model_validator(mode='after')` loader, but explicit so the + base class does not depend on pydantic.""" + raise NotImplementedError() + + # ---------------- defaults built on the cache ---------------- + + async def refresh(self) -> None: + """Re-sync the cache from the backend. Clears in-memory state and + re-invokes `load_artifacts()`. Callers use this after external writes.""" + self._artifacts.clear() + await self.load_artifacts() + + async def list_artifacts(self, prefix: str | None = None) -> list[Artifact[T]]: + """List from the in-memory cache. Override only if the backend must be + queried live.""" + if prefix is None: + return list(self._artifacts.values()) + return [a for k, a in self._artifacts.items() if k.startswith(prefix)] + + def keys(self, prefix: str = "") -> list[str]: + return [k for k in self._artifacts if k.startswith(prefix)] + + def __getitem__(self, path: str) -> Artifact[T]: + """Cache-only lookup. Raises KeyError on miss. For async + fetch-if-missing, use `await store.read_artifact(path)`.""" + return self._artifacts[path] + + def __contains__(self, path: str) -> bool: + return path in self._artifacts + + def __len__(self) -> int: + return len(self._artifacts) + + def __iter__(self): + return iter(self._artifacts) From f2b4d2299fc201ccbe687598ce9697d54ca74e2c Mon Sep 17 00:00:00 2001 From: NISH1001 Date: Tue, 21 Apr 2026 10:33:50 -0500 Subject: [PATCH 3/7] Add root param and fluent API to ArtifactStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add required `root` positional to __init__ so every store has a uniform identifier (filesystem path for LocalDir, `owner/repo/path` for GitHub, `bucket/prefix` for S3, path-prefix filter for DB) - `debug` is now keyword-only after `*` - load_artifacts and refresh return Self for fluent chaining, mirroring ada's `_load_artifacts` pattern — e.g. `store = await LocalDirArtifactStore(root).load_artifacts()` - Add __setitem__ and __delitem__ for cache-only mutation; subclasses use these inside read_artifact / write_artifact to keep the cache in sync after real backend I/O --- akd_ext/artifacts/_base.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/akd_ext/artifacts/_base.py b/akd_ext/artifacts/_base.py index 51246ef..2cb3449 100644 --- a/akd_ext/artifacts/_base.py +++ b/akd_ext/artifacts/_base.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from datetime import datetime -from typing import Any +from typing import Any, Self from pydantic import BaseModel, Field @@ -47,7 +47,8 @@ class ArtifactStore[T](ABC): stores populate them on read; caller-supplied values on write are ignored. """ - def __init__(self, debug: bool = False) -> None: + def __init__(self, root: str, *, debug: bool = False) -> None: + self.root = root self.debug = bool(debug) self._artifacts: dict[str, Artifact[T]] = {} @@ -65,20 +66,20 @@ async def write_artifact(self, artifact: Artifact[T]) -> Artifact[T]: raise NotImplementedError() @abstractmethod - async def load_artifacts(self) -> None: - """Populate `self._artifacts` from the backend. Called once after - construction (and again by `refresh()`). Functional counterpart of - ada's `@model_validator(mode='after')` loader, but explicit so the - base class does not depend on pydantic.""" + async def load_artifacts(self) -> Self: + """Populate `self._artifacts` from the backend and return `self` for + fluent chaining. Called once after construction (and again by + `refresh()`). Functional counterpart of ada's + `@model_validator(mode='after')` loader, but explicit so the base + class does not depend on pydantic.""" raise NotImplementedError() - # ---------------- defaults built on the cache ---------------- - - async def refresh(self) -> None: + async def refresh(self) -> Self: """Re-sync the cache from the backend. Clears in-memory state and - re-invokes `load_artifacts()`. Callers use this after external writes.""" + re-invokes `load_artifacts()`. Returns `self` for fluent chaining.""" self._artifacts.clear() await self.load_artifacts() + return self async def list_artifacts(self, prefix: str | None = None) -> list[Artifact[T]]: """List from the in-memory cache. Override only if the backend must be @@ -95,6 +96,16 @@ def __getitem__(self, path: str) -> Artifact[T]: fetch-if-missing, use `await store.read_artifact(path)`.""" return self._artifacts[path] + def __setitem__(self, path: str, artifact: Artifact[T]) -> None: + """Cache-only write. Does NOT persist to the backend. Subclasses use + this inside `write_artifact` / `read_artifact` to keep the cache in + sync after real I/O.""" + self._artifacts[path] = artifact + + def __delitem__(self, path: str) -> None: + """Cache-only removal. Does NOT delete from the backend.""" + del self._artifacts[path] + def __contains__(self, path: str) -> bool: return path in self._artifacts From 74be8a75ec7a791d722ae91e689d8f93eef94b3c Mon Sep 17 00:00:00 2001 From: NISH1001 Date: Tue, 21 Apr 2026 10:55:49 -0500 Subject: [PATCH 4/7] Add index_file param and index_for helper to ArtifactStore - Add `index_file: str | None = "index.md"` kwarg so stores can be configured per-backend convention (`index.md` for dev/local, `README.md` for GitHub, `SKILL.md` for Anthropic skills, `AGENT.md` for agent manifests, `None` for DB) - Add `index_for(dir_path)` helper that returns the designated overview artifact for a directory using the store's convention; tolerates leading/trailing slashes since paths are always relative to `root` - Use `PurePosixPath` for path joining inside the ABC so slug ops stay POSIX-safe across backends (no Windows backslash surprise) - Move `debug` to the last kwarg after `index_file` --- akd_ext/artifacts/_base.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/akd_ext/artifacts/_base.py b/akd_ext/artifacts/_base.py index 2cb3449..aa204a7 100644 --- a/akd_ext/artifacts/_base.py +++ b/akd_ext/artifacts/_base.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from datetime import datetime +from pathlib import PurePosixPath from typing import Any, Self from pydantic import BaseModel, Field @@ -47,11 +48,34 @@ class ArtifactStore[T](ABC): stores populate them on read; caller-supplied values on write are ignored. """ - def __init__(self, root: str, *, debug: bool = False) -> None: + def __init__( + self, + root: str, + *, + index_file: str | None = "index.md", + debug: bool = False, + ) -> None: self.root = root + self.index_file = index_file self.debug = bool(debug) self._artifacts: dict[str, Artifact[T]] = {} + def index_for(self, dir_path: str = "") -> Artifact[T] | None: + """Return the designated index artifact for a directory (or the + root overview if `dir_path` is empty). Returns None if `index_file` + is None or no such artifact is cached. + + Transparent across backend conventions: set `index_file` to + "index.md" (dev/local), "README.md" (GitHub), "SKILL.md" (Anthropic + skills), or "AGENT.md" (agent manifests) per store.""" + if not self.index_file: + return None + dir_path = dir_path.strip("/") + if not dir_path: + return self._artifacts.get(self.index_file) + key = str(PurePosixPath(dir_path) / self.index_file) + return self._artifacts.get(key) + @abstractmethod async def read_artifact(self, path: str) -> Artifact[T]: """Fetch a single artifact (content included). Raise on miss. From 0fe6edc76b4500a644c5e041aeedcf605dc73eaf Mon Sep 17 00:00:00 2001 From: NISH1001 Date: Tue, 21 Apr 2026 11:17:07 -0500 Subject: [PATCH 5/7] Add path validator to Artifact - Reject clearly broken paths at model creation time: empty/whitespace, null byte, `..` segments (ambiguous traversal) - Normalize obvious oddities via PurePosixPath: strip leading `/`, collapse `//` and `./` sequences - Backend-specific path-traversal defense still belongs inside each store (e.g. LocalDirArtifactStore checking `is_relative_to(root)`) - Keeps the model contract strict enough to catch typos/footguns without over-prescribing legitimate path shapes --- akd_ext/artifacts/_base.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/akd_ext/artifacts/_base.py b/akd_ext/artifacts/_base.py index aa204a7..bcd41c8 100644 --- a/akd_ext/artifacts/_base.py +++ b/akd_ext/artifacts/_base.py @@ -3,7 +3,7 @@ from pathlib import PurePosixPath from typing import Any, Self -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class Artifact[T](BaseModel): @@ -33,6 +33,25 @@ class Artifact[T](BaseModel): ), ) + @field_validator("path") + @classmethod + def _normalize_path(cls, v: str) -> str: + """Reject clearly broken paths; normalize obvious oddities. + + - Rejects: empty/whitespace-only, null byte, '..' segments. + - Normalizes: leading '/' stripped, '//' collapsed, './' collapsed. + - Backend-specific path-traversal defense still lives in each store. + """ + v = v.strip() + if not v: + raise ValueError("path cannot be empty") + if "\0" in v: + raise ValueError("path contains null byte") + p = PurePosixPath(v.lstrip("/")) + if ".." in p.parts: + raise ValueError(f"path contains '..' segment: {v!r}") + return str(p) + class ArtifactStore[T](ABC): """ From fd86859a736479e7045456ea5c3c56ec1db995b3 Mon Sep 17 00:00:00 2001 From: NISH1001 Date: Tue, 21 Apr 2026 11:31:02 -0500 Subject: [PATCH 6/7] Add __str__ to ArtifactStore for system-prompt injection - Flat bulleted list of paths with descriptions where available, matching ada's pattern: `"\n".join(f"- {k}" for k in store.keys())` - Each line contains the exact path the caller passes to read_artifact, no mental reconstruction from indentation needed (LLM-friendly) - Drops straight into an f-string in the pydantic-ai system_prompt: `f"## Available artifacts\n{store}\n\nUse read_artifact(path=...)"` --- akd_ext/artifacts/_base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/akd_ext/artifacts/_base.py b/akd_ext/artifacts/_base.py index bcd41c8..858af2e 100644 --- a/akd_ext/artifacts/_base.py +++ b/akd_ext/artifacts/_base.py @@ -157,3 +157,14 @@ def __len__(self) -> int: def __iter__(self): return iter(self._artifacts) + + def __str__(self) -> str: + """Flat bulleted list of paths (with descriptions where available), + suitable for dropping into an LLM system prompt. Each line contains + the exact path the caller should pass to `read_artifact`.""" + lines = [] + for path in sorted(self._artifacts): + a = self._artifacts[path] + desc = f" — {a.description}" if a.description else "" + lines.append(f"- {path}{desc}") + return "\n".join(lines) From 5cf6e51a085219558d055174887f95148c1c78ea Mon Sep 17 00:00:00 2001 From: NISH1001 Date: Tue, 21 Apr 2026 11:43:33 -0500 Subject: [PATCH 7/7] Add barebone tests and stores subpackage stub - Add 3 barebone tests covering the core ArtifactStore ops (load, read, write) via a minimal in-memory concrete subclass - Stub out akd_ext/artifacts/stores/ as an empty subpackage so future backends (LocalDir, GitHub, S3) each land in their own file and can gate optional deps independently --- akd_ext/artifacts/stores/__init__.py | 0 tests/artifacts/__init__.py | 0 tests/artifacts/test_base.py | 32 ++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 akd_ext/artifacts/stores/__init__.py create mode 100644 tests/artifacts/__init__.py create mode 100644 tests/artifacts/test_base.py diff --git a/akd_ext/artifacts/stores/__init__.py b/akd_ext/artifacts/stores/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/artifacts/__init__.py b/tests/artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/artifacts/test_base.py b/tests/artifacts/test_base.py new file mode 100644 index 0000000..46aaebf --- /dev/null +++ b/tests/artifacts/test_base.py @@ -0,0 +1,32 @@ +"""Barebone tests for ArtifactStore: load, read, write.""" + +from akd_ext.artifacts import Artifact, ArtifactStore + + +class _MemStore(ArtifactStore[str]): + async def load_artifacts(self): + self["test.md"] = Artifact[str](path="test.md", content="hi") + return self + + async def read_artifact(self, path: str) -> Artifact[str]: + return self[path] + + async def write_artifact(self, artifact: Artifact[str]) -> Artifact[str]: + self[artifact.path] = artifact + return artifact + + +async def test_load(): + store = await _MemStore(root="mem://").load_artifacts() + assert "test.md" in store + + +async def test_read(): + store = await _MemStore(root="mem://").load_artifacts() + assert (await store.read_artifact("test.md")).content == "hi" + + +async def test_write(): + store = _MemStore(root="mem://") + await store.write_artifact(Artifact[str](path="new.md", content="fresh")) + assert (await store.read_artifact("new.md")).content == "fresh"