kbforge 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.
- kbforge/__init__.py +9 -0
- kbforge/__main__.py +125 -0
- kbforge/canonical.py +49 -0
- kbforge/connectors/__init__.py +0 -0
- kbforge/connectors/git_commits.py +150 -0
- kbforge/connectors/local_files.py +175 -0
- kbforge/hookspecs.py +68 -0
- kbforge/mirror.py +60 -0
- kbforge/models.py +121 -0
- kbforge/pipeline.py +123 -0
- kbforge/publishers/__init__.py +0 -0
- kbforge/publishers/dry_run.py +46 -0
- kbforge/py.typed +0 -0
- kbforge/registry.py +34 -0
- kbforge/synthesize.py +92 -0
- kbforge/validate.py +229 -0
- kbforge-0.1.0.dist-info/METADATA +152 -0
- kbforge-0.1.0.dist-info/RECORD +21 -0
- kbforge-0.1.0.dist-info/WHEEL +4 -0
- kbforge-0.1.0.dist-info/entry_points.txt +2 -0
- kbforge-0.1.0.dist-info/licenses/LICENSE +21 -0
kbforge/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""kbforge — agent-first knowledge bases, forged from your systems of record.
|
|
2
|
+
|
|
3
|
+
The production half of the Open Knowledge Format: connectors, canonicalization,
|
|
4
|
+
diff, provenance, and publish. See docs/architecture.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["__version__"]
|
kbforge/__main__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""`python -m kbforge ...` — the walking-skeleton entry point.
|
|
2
|
+
|
|
3
|
+
Connector selection and config are fully generic: the connector is resolved by
|
|
4
|
+
name from the registry (built-in or entry-point-discovered), and its config comes
|
|
5
|
+
from repeatable `--set KEY=VALUE` pairs. Nothing here knows a connector's config
|
|
6
|
+
shape, so a third-party plugin is usable with no change to this file."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
import pluggy
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from kbforge.pipeline import (
|
|
17
|
+
Aborted,
|
|
18
|
+
ConfigError,
|
|
19
|
+
ConnectorProtocol,
|
|
20
|
+
NoOp,
|
|
21
|
+
Published,
|
|
22
|
+
PublisherProtocol,
|
|
23
|
+
run,
|
|
24
|
+
)
|
|
25
|
+
from kbforge.registry import build_registry
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _connectors(pm: pluggy.PluginManager) -> dict[str, ConnectorProtocol]:
|
|
29
|
+
"""name -> connector instance (a connector implements kbforge_fetch)."""
|
|
30
|
+
return {
|
|
31
|
+
p.kbforge_connector_info().name: cast(ConnectorProtocol, p)
|
|
32
|
+
for p in pm.get_plugins()
|
|
33
|
+
if hasattr(p, "kbforge_fetch")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _publisher(pm: pluggy.PluginManager) -> PublisherProtocol:
|
|
38
|
+
for p in pm.get_plugins():
|
|
39
|
+
if hasattr(p, "kbforge_publish"):
|
|
40
|
+
return cast(PublisherProtocol, p)
|
|
41
|
+
raise SystemExit("no publisher registered")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_settings(pairs: list[str]) -> dict:
|
|
45
|
+
"""`KEY=VALUE` pairs into a config dict; VALUE is YAML-typed so `max_commits=5`
|
|
46
|
+
is an int, `ref=HEAD` a str, and `ignore_globs=[a, b]` a list."""
|
|
47
|
+
config: dict = {}
|
|
48
|
+
for pair in pairs:
|
|
49
|
+
key, sep, raw = pair.partition("=")
|
|
50
|
+
if not sep:
|
|
51
|
+
raise ValueError(f"--set expects KEY=VALUE, got {pair!r}")
|
|
52
|
+
config[key] = yaml.safe_load(raw)
|
|
53
|
+
return config
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
parser = argparse.ArgumentParser(prog="kbforge")
|
|
58
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
59
|
+
|
|
60
|
+
sub.add_parser("list", help="list available connectors")
|
|
61
|
+
|
|
62
|
+
r = sub.add_parser("run", help="run the pipeline once")
|
|
63
|
+
r.add_argument("--connector", required=True)
|
|
64
|
+
r.add_argument(
|
|
65
|
+
"--set",
|
|
66
|
+
action="append",
|
|
67
|
+
default=[],
|
|
68
|
+
dest="settings",
|
|
69
|
+
metavar="KEY=VALUE",
|
|
70
|
+
help="connector config (repeatable); values are YAML-typed",
|
|
71
|
+
)
|
|
72
|
+
r.add_argument("--mirror", required=True)
|
|
73
|
+
r.add_argument("--out", required=True)
|
|
74
|
+
r.add_argument("--state", required=True)
|
|
75
|
+
args = parser.parse_args(argv)
|
|
76
|
+
|
|
77
|
+
pm = build_registry()
|
|
78
|
+
connectors = _connectors(pm)
|
|
79
|
+
|
|
80
|
+
if args.cmd == "list":
|
|
81
|
+
for name in sorted(connectors):
|
|
82
|
+
info = connectors[name].kbforge_connector_info()
|
|
83
|
+
print(f"{name}\t{info.source_system}")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
if args.connector not in connectors:
|
|
87
|
+
available = ", ".join(sorted(connectors)) or "(none)"
|
|
88
|
+
print(f"unknown connector {args.connector!r}; available: {available}")
|
|
89
|
+
return 2
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
config = _parse_settings(args.settings)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
print(str(exc))
|
|
95
|
+
return 2
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = run(
|
|
99
|
+
connectors[args.connector],
|
|
100
|
+
_publisher(pm),
|
|
101
|
+
config=config,
|
|
102
|
+
mirror=args.mirror,
|
|
103
|
+
state_dir=args.state,
|
|
104
|
+
publish_config={"out_dir": args.out},
|
|
105
|
+
)
|
|
106
|
+
except ConfigError as exc:
|
|
107
|
+
print(str(exc))
|
|
108
|
+
return 2
|
|
109
|
+
|
|
110
|
+
if isinstance(result, Published):
|
|
111
|
+
print(f"Published: {result.url}")
|
|
112
|
+
return 0
|
|
113
|
+
if isinstance(result, NoOp):
|
|
114
|
+
print("NoOp: no change detected; no MR opened.")
|
|
115
|
+
return 0
|
|
116
|
+
if isinstance(result, Aborted):
|
|
117
|
+
print(f"Aborted: {len(result.failures)} validation failure(s):")
|
|
118
|
+
for f in result.failures:
|
|
119
|
+
print(f" [{f.law}] {f.concept_path}: {f.message}")
|
|
120
|
+
return 1
|
|
121
|
+
return 2
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__": # pragma: no cover
|
|
125
|
+
raise SystemExit(main())
|
kbforge/canonical.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Canonicalization: stable content hashing and the §4.3 law-1 stability check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
|
|
9
|
+
from kbforge.models import CanonicalDocument, RawRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StabilityError(RuntimeError):
|
|
13
|
+
"""normalize() produced different canonical content for identical input."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def content_hash(doc: CanonicalDocument) -> str:
|
|
17
|
+
"""SHA-256 over the canonical CONTENT — everything the diff must react to.
|
|
18
|
+
The anchor is excluded: `retrieved_at` is volatile (§4.3 law 2) and the
|
|
19
|
+
anchor's own content_hash would be circular."""
|
|
20
|
+
payload = {
|
|
21
|
+
"doc_id": doc.doc_id,
|
|
22
|
+
"title": doc.title,
|
|
23
|
+
"text": doc.text,
|
|
24
|
+
"structured": doc.structured,
|
|
25
|
+
"relations": sorted(doc.relations),
|
|
26
|
+
"deleted": doc.deleted,
|
|
27
|
+
}
|
|
28
|
+
# default=str keeps determinism while tolerating YAML-parsed date/datetime
|
|
29
|
+
# values (PyYAML turns bare `2024-05-01` into a date, which json can't dump).
|
|
30
|
+
blob = json.dumps(
|
|
31
|
+
payload,
|
|
32
|
+
sort_keys=True,
|
|
33
|
+
ensure_ascii=True,
|
|
34
|
+
separators=(",", ":"),
|
|
35
|
+
default=str,
|
|
36
|
+
)
|
|
37
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def assert_stability(
|
|
41
|
+
normalize: Callable[[Sequence[RawRecord]], list[CanonicalDocument]],
|
|
42
|
+
records: Sequence[RawRecord],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""§4.3 law 1: normalize twice over identical input, require identical content
|
|
45
|
+
hashes. A connector that fails is not deterministic and must be rejected."""
|
|
46
|
+
first = [content_hash(d) for d in normalize(records)]
|
|
47
|
+
second = [content_hash(d) for d in normalize(records)]
|
|
48
|
+
if first != second:
|
|
49
|
+
raise StabilityError("normalize() is not deterministic over identical input")
|
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""A git-history connector: each commit reachable from a ref becomes one canonical
|
|
2
|
+
document. No credentials, no network — reads a local repository with `git log`.
|
|
3
|
+
|
|
4
|
+
Unlike the feed-less local_files connector, this one uses the cursor for real: the
|
|
5
|
+
watermark is the last-synced commit SHA, so an incremental fetch returns only
|
|
6
|
+
`<last_sha>..<ref>` — the first live exercise of the pipeline's incremental path.
|
|
7
|
+
A commit is immutable, so its content hash never changes and a re-seen commit is a
|
|
8
|
+
clean no-op."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from kbforge.canonical import content_hash
|
|
18
|
+
from kbforge.hookspecs import hookimpl
|
|
19
|
+
from kbforge.models import (
|
|
20
|
+
CanonicalDocument,
|
|
21
|
+
ConnectorInfo,
|
|
22
|
+
Cursor,
|
|
23
|
+
FetchResult,
|
|
24
|
+
RawRecord,
|
|
25
|
+
ResourceAnchor,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_SYSTEM = "git_commits"
|
|
29
|
+
_UNIT = "\x1f" # field separator inside one commit's formatted record
|
|
30
|
+
# sha, author name, author email, author date (ISO), committer date (ISO), subject,
|
|
31
|
+
# body — body is last because it may contain newlines (records are NUL-delimited).
|
|
32
|
+
_FORMAT = _UNIT.join(["%H", "%an", "%ae", "%aI", "%cI", "%s", "%b"])
|
|
33
|
+
_FIELDS = 7
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _git(repo: Path, *args: str, check: bool = True) -> str:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["git", *args],
|
|
39
|
+
cwd=repo,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
check=check,
|
|
43
|
+
)
|
|
44
|
+
return result.stdout
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GitCommitsConnector:
|
|
48
|
+
@hookimpl
|
|
49
|
+
def kbforge_connector_info(self) -> ConnectorInfo:
|
|
50
|
+
return ConnectorInfo(
|
|
51
|
+
name=_SYSTEM,
|
|
52
|
+
version="0.1.0",
|
|
53
|
+
source_system="git history (local repository)",
|
|
54
|
+
info_types=["commit"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@hookimpl
|
|
58
|
+
def kbforge_validate_config(self, config: dict) -> list[str]:
|
|
59
|
+
problems: list[str] = []
|
|
60
|
+
repo = config.get("repo")
|
|
61
|
+
if not repo or not Path(repo).is_dir():
|
|
62
|
+
problems.append(f"config 'repo' is not a readable directory: {repo!r}")
|
|
63
|
+
elif not _git(Path(repo), "rev-parse", "--git-dir", check=False).strip():
|
|
64
|
+
problems.append(f"config 'repo' is not a git repository: {repo!r}")
|
|
65
|
+
max_commits = config.get("max_commits")
|
|
66
|
+
if max_commits is not None and (
|
|
67
|
+
not isinstance(max_commits, int) or isinstance(max_commits, bool)
|
|
68
|
+
):
|
|
69
|
+
problems.append(f"config 'max_commits' must be an int: {max_commits!r}")
|
|
70
|
+
return problems
|
|
71
|
+
|
|
72
|
+
@hookimpl
|
|
73
|
+
def kbforge_fetch(self, config: dict, cursor: Cursor | None) -> FetchResult:
|
|
74
|
+
repo = Path(config["repo"])
|
|
75
|
+
ref = config.get("ref", "HEAD")
|
|
76
|
+
tip = _git(repo, "rev-parse", ref, check=False).strip()
|
|
77
|
+
if not tip:
|
|
78
|
+
# Empty repo / unknown ref: nothing to sync, watermark stays put.
|
|
79
|
+
return FetchResult(
|
|
80
|
+
records=[], cursor=Cursor(connector=_SYSTEM, payload={"ref": ref})
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# cursor=None → backfill everything reachable from ref (bounded by
|
|
84
|
+
# max_commits); an existing watermark → only commits since it (`last..ref`).
|
|
85
|
+
last_sha = cursor.payload.get("last_sha") if cursor else None
|
|
86
|
+
rev_range = f"{last_sha}..{ref}" if last_sha else ref
|
|
87
|
+
log_args = ["log", rev_range, f"--format={_FORMAT}", "-z"]
|
|
88
|
+
max_commits = config.get("max_commits")
|
|
89
|
+
if not last_sha and max_commits is not None:
|
|
90
|
+
log_args.append(f"--max-count={max_commits}")
|
|
91
|
+
|
|
92
|
+
raw = _git(repo, *log_args)
|
|
93
|
+
records: list[RawRecord] = []
|
|
94
|
+
for chunk in raw.split("\x00"):
|
|
95
|
+
if not chunk.strip():
|
|
96
|
+
continue
|
|
97
|
+
parts = chunk.split(_UNIT)
|
|
98
|
+
if len(parts) < _FIELDS:
|
|
99
|
+
continue
|
|
100
|
+
sha, author, email, adate, cdate, subject, body = parts[:_FIELDS]
|
|
101
|
+
records.append(
|
|
102
|
+
RawRecord(
|
|
103
|
+
anchor_hint={
|
|
104
|
+
"native_id": sha,
|
|
105
|
+
"url": None,
|
|
106
|
+
"retrieved_at": cdate,
|
|
107
|
+
"author": author,
|
|
108
|
+
"author_email": email,
|
|
109
|
+
"author_date": adate,
|
|
110
|
+
"subject": subject,
|
|
111
|
+
},
|
|
112
|
+
media_type="text/x-git-commit",
|
|
113
|
+
payload=body.encode("utf-8"),
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return FetchResult(
|
|
117
|
+
records=records,
|
|
118
|
+
cursor=Cursor(connector=_SYSTEM, payload={"last_sha": tip, "ref": ref}),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@hookimpl
|
|
122
|
+
def kbforge_normalize(
|
|
123
|
+
self, records: Sequence[RawRecord]
|
|
124
|
+
) -> list[CanonicalDocument]:
|
|
125
|
+
docs: list[CanonicalDocument] = []
|
|
126
|
+
for rec in records:
|
|
127
|
+
hint = rec.anchor_hint
|
|
128
|
+
sha = hint["native_id"]
|
|
129
|
+
anchor = ResourceAnchor(
|
|
130
|
+
system=_SYSTEM,
|
|
131
|
+
native_id=sha,
|
|
132
|
+
url=hint.get("url"),
|
|
133
|
+
retrieved_at=datetime.fromisoformat(hint["retrieved_at"]),
|
|
134
|
+
content_hash="",
|
|
135
|
+
)
|
|
136
|
+
doc = CanonicalDocument(
|
|
137
|
+
anchor=anchor,
|
|
138
|
+
doc_id=f"{_SYSTEM}:{sha}",
|
|
139
|
+
title=hint.get("subject") or sha[:12],
|
|
140
|
+
text=rec.payload.decode("utf-8").strip(),
|
|
141
|
+
structured={
|
|
142
|
+
"author": hint.get("author"),
|
|
143
|
+
"author_email": hint.get("author_email"),
|
|
144
|
+
"author_date": hint.get("author_date"),
|
|
145
|
+
},
|
|
146
|
+
relations=[],
|
|
147
|
+
)
|
|
148
|
+
doc.anchor.content_hash = content_hash(doc)
|
|
149
|
+
docs.append(doc)
|
|
150
|
+
return docs
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""A fixture connector: a folder of markdown-with-frontmatter → canonical docs.
|
|
2
|
+
No credentials, no network — the deterministic source for the walking skeleton."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from fnmatch import fnmatch
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from kbforge.canonical import content_hash
|
|
14
|
+
from kbforge.hookspecs import hookimpl
|
|
15
|
+
from kbforge.models import (
|
|
16
|
+
CanonicalDocument,
|
|
17
|
+
ConnectorInfo,
|
|
18
|
+
Cursor,
|
|
19
|
+
FetchResult,
|
|
20
|
+
RawRecord,
|
|
21
|
+
ResourceAnchor,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_SYSTEM = "local_files"
|
|
25
|
+
# Keys handled structurally, so they never leak into `structured` (hence facets):
|
|
26
|
+
# title → the concept title; relations → cross-links; type is dropped here because
|
|
27
|
+
# the OKF type comes from synthesis taxonomy (the stub emits "concept"); description,
|
|
28
|
+
# timestamp, resource, and links are emit-side OKF fields the synthesizer owns — a
|
|
29
|
+
# source key of the same name must not collide with them in the rendered frontmatter.
|
|
30
|
+
_RESERVED_KEYS = frozenset(
|
|
31
|
+
{"type", "title", "relations", "description", "timestamp", "resource", "links"}
|
|
32
|
+
)
|
|
33
|
+
# Dependency, VCS, and tool-cache directories that a blind rglob would sweep into
|
|
34
|
+
# the KB (a real live test pulled 98 vendored `.venv` docs of 132 total). These
|
|
35
|
+
# always apply; a source's `ignore_globs` config ADDS to them, never replaces them —
|
|
36
|
+
# so adding one custom pattern can't silently re-enable `.venv`.
|
|
37
|
+
_DEFAULT_IGNORES = frozenset(
|
|
38
|
+
{
|
|
39
|
+
".git",
|
|
40
|
+
".venv",
|
|
41
|
+
"venv",
|
|
42
|
+
"node_modules",
|
|
43
|
+
".pytest_cache",
|
|
44
|
+
"__pycache__",
|
|
45
|
+
".mypy_cache",
|
|
46
|
+
".ruff_cache",
|
|
47
|
+
".ipynb_checkpoints",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_ignored(rel: str, patterns: frozenset[str]) -> bool:
|
|
53
|
+
"""True if the posix-relative path is excluded. A pattern matches either any
|
|
54
|
+
single path segment (a bare dir name like `.venv` skips the whole subtree) or
|
|
55
|
+
the full relative path (a glob like `drafts/*` or `_draft*`)."""
|
|
56
|
+
segments = rel.split("/")
|
|
57
|
+
return any(
|
|
58
|
+
fnmatch(rel, pat) or any(fnmatch(seg, pat) for seg in segments)
|
|
59
|
+
for pat in patterns
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _split_frontmatter(text: str) -> tuple[dict, str]:
|
|
64
|
+
if not text.startswith("---"):
|
|
65
|
+
return {}, text
|
|
66
|
+
_, _, rest = text.partition("---")
|
|
67
|
+
front_raw, sep, body = rest.partition("\n---")
|
|
68
|
+
if not sep:
|
|
69
|
+
return {}, text
|
|
70
|
+
try:
|
|
71
|
+
data = yaml.safe_load(front_raw) or {}
|
|
72
|
+
except yaml.YAMLError:
|
|
73
|
+
# Malformed frontmatter (e.g. an unquoted colon) must not crash the whole
|
|
74
|
+
# sync: drop the unparseable frontmatter, keep the clean body.
|
|
75
|
+
data = {}
|
|
76
|
+
front = data if isinstance(data, dict) else {}
|
|
77
|
+
return front, body.lstrip("\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LocalFilesConnector:
|
|
81
|
+
@hookimpl
|
|
82
|
+
def kbforge_connector_info(self) -> ConnectorInfo:
|
|
83
|
+
return ConnectorInfo(
|
|
84
|
+
name=_SYSTEM,
|
|
85
|
+
version="0.1.0",
|
|
86
|
+
source_system="local filesystem (fixture)",
|
|
87
|
+
info_types=["fixture"],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@hookimpl
|
|
91
|
+
def kbforge_validate_config(self, config: dict) -> list[str]:
|
|
92
|
+
problems: list[str] = []
|
|
93
|
+
path = config.get("path")
|
|
94
|
+
if not path or not Path(path).is_dir():
|
|
95
|
+
problems.append(f"config 'path' is not a readable directory: {path!r}")
|
|
96
|
+
ignore = config.get("ignore_globs")
|
|
97
|
+
if ignore is not None and (
|
|
98
|
+
not isinstance(ignore, list) or not all(isinstance(g, str) for g in ignore)
|
|
99
|
+
):
|
|
100
|
+
problems.append(
|
|
101
|
+
f"config 'ignore_globs' must be a list of strings: {ignore!r}"
|
|
102
|
+
)
|
|
103
|
+
return problems
|
|
104
|
+
|
|
105
|
+
@hookimpl
|
|
106
|
+
def kbforge_fetch(self, config: dict, cursor: Cursor | None) -> FetchResult:
|
|
107
|
+
# cursor is unused: this feed-less source always re-scans. The mirror diff
|
|
108
|
+
# detects adds and modifies; it does NOT derive deletions — a full-scan
|
|
109
|
+
# source can't tell a deleted file from an absent one, so a removed file
|
|
110
|
+
# leaves a stale concept until a tombstone / `complete`-aware diff lands (a
|
|
111
|
+
# later increment; `FetchResult.complete` is defined but not yet consumed).
|
|
112
|
+
# retrieved_at is stamped here (fetch may use a clock; normalize may not)
|
|
113
|
+
# from file mtime, keeping runs reproducible.
|
|
114
|
+
root = Path(config["path"])
|
|
115
|
+
ignores = _DEFAULT_IGNORES | frozenset(config.get("ignore_globs") or [])
|
|
116
|
+
records: list[RawRecord] = []
|
|
117
|
+
for path in sorted(root.rglob("*.md")):
|
|
118
|
+
rel = path.relative_to(root).as_posix()
|
|
119
|
+
if _is_ignored(rel, ignores):
|
|
120
|
+
continue
|
|
121
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
|
|
122
|
+
records.append(
|
|
123
|
+
RawRecord(
|
|
124
|
+
anchor_hint={
|
|
125
|
+
"native_id": rel,
|
|
126
|
+
"url": None,
|
|
127
|
+
"retrieved_at": mtime.isoformat(),
|
|
128
|
+
},
|
|
129
|
+
media_type="text/markdown",
|
|
130
|
+
payload=path.read_bytes(),
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return FetchResult(records=records, cursor=Cursor(connector=_SYSTEM))
|
|
134
|
+
|
|
135
|
+
@hookimpl
|
|
136
|
+
def kbforge_normalize(
|
|
137
|
+
self, records: Sequence[RawRecord]
|
|
138
|
+
) -> list[CanonicalDocument]:
|
|
139
|
+
docs: list[CanonicalDocument] = []
|
|
140
|
+
for rec in records:
|
|
141
|
+
# utf-8-sig strips a BOM (Windows editors) that would otherwise defeat
|
|
142
|
+
# the `startswith("---")` check; line-ending normalization keeps CRLF
|
|
143
|
+
# and LF copies of the same content hashing identically (§4.3 law 1).
|
|
144
|
+
text = (
|
|
145
|
+
rec.payload.decode("utf-8-sig")
|
|
146
|
+
.replace("\r\n", "\n")
|
|
147
|
+
.replace("\r", "\n")
|
|
148
|
+
)
|
|
149
|
+
front, body = _split_frontmatter(text)
|
|
150
|
+
native_id = rec.anchor_hint["native_id"]
|
|
151
|
+
doc_id = f"{_SYSTEM}:{native_id}"
|
|
152
|
+
relations = sorted(
|
|
153
|
+
f"{_SYSTEM}:{r}"
|
|
154
|
+
for r in front.get("relations", [])
|
|
155
|
+
if isinstance(r, str)
|
|
156
|
+
)
|
|
157
|
+
structured = {k: v for k, v in front.items() if k not in _RESERVED_KEYS}
|
|
158
|
+
anchor = ResourceAnchor(
|
|
159
|
+
system=_SYSTEM,
|
|
160
|
+
native_id=native_id,
|
|
161
|
+
url=rec.anchor_hint.get("url"),
|
|
162
|
+
retrieved_at=datetime.fromisoformat(rec.anchor_hint["retrieved_at"]),
|
|
163
|
+
content_hash="",
|
|
164
|
+
)
|
|
165
|
+
doc = CanonicalDocument(
|
|
166
|
+
anchor=anchor,
|
|
167
|
+
doc_id=doc_id,
|
|
168
|
+
title=str(front.get("title") or native_id),
|
|
169
|
+
text=body.strip(),
|
|
170
|
+
structured=structured,
|
|
171
|
+
relations=relations,
|
|
172
|
+
)
|
|
173
|
+
doc.anchor.content_hash = content_hash(doc)
|
|
174
|
+
docs.append(doc)
|
|
175
|
+
return docs
|
kbforge/hookspecs.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Pluggy hookspecs. The connector and publisher interfaces ARE the product (§5).
|
|
2
|
+
Kept minimal for the walking skeleton: one connector family, one publisher family."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
import pluggy
|
|
10
|
+
|
|
11
|
+
from kbforge.models import (
|
|
12
|
+
CanonicalDocument,
|
|
13
|
+
ConnectorInfo,
|
|
14
|
+
Cursor,
|
|
15
|
+
FetchResult,
|
|
16
|
+
ProposedChange,
|
|
17
|
+
RawRecord,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
PROJECT = "kbforge"
|
|
21
|
+
hookspec = pluggy.HookspecMarker(PROJECT)
|
|
22
|
+
hookimpl = pluggy.HookimplMarker(PROJECT)
|
|
23
|
+
|
|
24
|
+
# Entry-point groups a third-party distribution advertises to be discovered. Kept
|
|
25
|
+
# separate so a package declares intent: a connector plugin vs a publisher plugin.
|
|
26
|
+
CONNECTOR_ENTRYPOINTS = "kbforge.connectors"
|
|
27
|
+
PUBLISHER_ENTRYPOINTS = "kbforge.publishers"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConnectorSpec(ABC):
|
|
31
|
+
"""One plugin object per system of record. Connectors never see the bundle,
|
|
32
|
+
never call the LLM, never touch git (§4.1)."""
|
|
33
|
+
|
|
34
|
+
@hookspec
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def kbforge_connector_info(self) -> ConnectorInfo:
|
|
37
|
+
"""Static self-description."""
|
|
38
|
+
|
|
39
|
+
@hookspec
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def kbforge_validate_config(self, config: dict) -> list[str]:
|
|
42
|
+
"""Return human-readable problems ([] = ok). No network I/O."""
|
|
43
|
+
|
|
44
|
+
@hookspec
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def kbforge_fetch(self, config: dict, cursor: Cursor | None) -> FetchResult:
|
|
47
|
+
"""Pull raw records (cursor=None = full backfill / bootstrap)."""
|
|
48
|
+
|
|
49
|
+
@hookspec
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def kbforge_normalize(
|
|
52
|
+
self, records: Sequence[RawRecord]
|
|
53
|
+
) -> list[CanonicalDocument]:
|
|
54
|
+
"""Deterministic, volatile-free, clock-free (§4.3)."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PublisherSpec(ABC):
|
|
58
|
+
"""Where proposals go. MUST NOT merge (§5.2)."""
|
|
59
|
+
|
|
60
|
+
@hookspec
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def kbforge_publisher_info(self) -> ConnectorInfo:
|
|
63
|
+
"""Static self-description."""
|
|
64
|
+
|
|
65
|
+
@hookspec
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def kbforge_publish(self, change: ProposedChange, config: dict) -> str:
|
|
68
|
+
"""Open a review request; return its URL/path. Never merges."""
|
kbforge/mirror.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The canonical mirror and the read-only diff (architecture §7's mirror_and_diff,
|
|
2
|
+
split into a pure `diff` and a success-only `commit`)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from kbforge.models import CanonicalDocument, ChangeSet
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _slot(mirror: Path, doc_id: str) -> Path:
|
|
13
|
+
key = hashlib.sha256(doc_id.encode("utf-8")).hexdigest()
|
|
14
|
+
return mirror / f"{key}.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load(mirror: Path, doc_id: str) -> CanonicalDocument | None:
|
|
18
|
+
slot = _slot(mirror, doc_id)
|
|
19
|
+
if not slot.exists():
|
|
20
|
+
return None
|
|
21
|
+
return CanonicalDocument.model_validate_json(slot.read_text("utf-8"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def diff(mirror: Path, docs: list[CanonicalDocument]) -> ChangeSet:
|
|
25
|
+
"""Read-only comparison against the mirror. Deletions are explicit tombstones
|
|
26
|
+
(`deleted=True`); absence never implies one (§4.2). Never mutates the mirror."""
|
|
27
|
+
added: list[str] = []
|
|
28
|
+
modified: list[str] = []
|
|
29
|
+
removed: list[str] = []
|
|
30
|
+
unchanged = 0
|
|
31
|
+
for doc in docs:
|
|
32
|
+
prev = _load(mirror, doc.doc_id)
|
|
33
|
+
if doc.deleted:
|
|
34
|
+
if prev is not None:
|
|
35
|
+
removed.append(doc.doc_id)
|
|
36
|
+
continue
|
|
37
|
+
if prev is None:
|
|
38
|
+
added.append(doc.doc_id)
|
|
39
|
+
elif prev.anchor.content_hash != doc.anchor.content_hash:
|
|
40
|
+
modified.append(doc.doc_id)
|
|
41
|
+
else:
|
|
42
|
+
unchanged += 1
|
|
43
|
+
return ChangeSet(
|
|
44
|
+
added=sorted(added),
|
|
45
|
+
modified=sorted(modified),
|
|
46
|
+
removed=sorted(removed),
|
|
47
|
+
unchanged_count=unchanged,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def commit(mirror: Path, docs: list[CanonicalDocument]) -> None:
|
|
52
|
+
"""Advance the mirror to the fetched state. Called only after a run fully
|
|
53
|
+
succeeds, so a failed publish never leaves the mirror ahead of the bundle."""
|
|
54
|
+
mirror.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
for doc in docs:
|
|
56
|
+
slot = _slot(mirror, doc.doc_id)
|
|
57
|
+
if doc.deleted:
|
|
58
|
+
slot.unlink(missing_ok=True)
|
|
59
|
+
else:
|
|
60
|
+
slot.write_text(doc.model_dump_json(), "utf-8")
|