sarib 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sarib-0.1.0/PKG-INFO +55 -0
- sarib-0.1.0/README.md +35 -0
- sarib-0.1.0/pyproject.toml +36 -0
- sarib-0.1.0/sarib/__init__.py +10 -0
- sarib-0.1.0/sarib/canon.py +42 -0
- sarib-0.1.0/sarib/cli.py +81 -0
- sarib-0.1.0/sarib/importer.py +305 -0
- sarib-0.1.0/sarib/mcp_server.py +82 -0
- sarib-0.1.0/sarib/model.py +101 -0
- sarib-0.1.0/sarib/ops.py +110 -0
- sarib-0.1.0/sarib/parser.py +202 -0
- sarib-0.1.0/sarib/query.py +99 -0
- sarib-0.1.0/sarib/render.py +99 -0
- sarib-0.1.0/sarib.egg-info/PKG-INFO +55 -0
- sarib-0.1.0/sarib.egg-info/SOURCES.txt +18 -0
- sarib-0.1.0/sarib.egg-info/dependency_links.txt +1 -0
- sarib-0.1.0/sarib.egg-info/entry_points.txt +3 -0
- sarib-0.1.0/sarib.egg-info/requires.txt +3 -0
- sarib-0.1.0/sarib.egg-info/top_level.txt +1 -0
- sarib-0.1.0/setup.cfg +4 -0
sarib-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sarib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reference implementation of the .sarib knowledge language: query bounded subgraphs and apply id-addressed atomic edits over plain-text knowledge.
|
|
5
|
+
Author: Syed Sarib Sultan
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SyedSaribSultan/sarib-lang
|
|
8
|
+
Project-URL: Repository, https://github.com/SyedSaribSultan/sarib-lang
|
|
9
|
+
Project-URL: Issues, https://github.com/SyedSaribSultan/sarib-lang/issues
|
|
10
|
+
Keywords: knowledge,graph,markdown,mcp,ai-agents,llm,notes,property-graph
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Provides-Extra: mcp
|
|
19
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
20
|
+
|
|
21
|
+
# sarib
|
|
22
|
+
|
|
23
|
+
Reference implementation of the **.sarib** knowledge language — a plain-text format
|
|
24
|
+
that is at once a readable document (a Markdown superset) and a labeled property graph.
|
|
25
|
+
Agents query bounded subgraphs and apply id-addressed atomic edits instead of
|
|
26
|
+
re-reading or regenerating whole files.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install sarib # core, zero dependencies
|
|
30
|
+
pip install "sarib[mcp]" # + the MCP server for agent clients
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## CLI
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
sarib validate notes.sarib
|
|
37
|
+
sarib render notes.sarib --view outline # document | outline | board | mermaid
|
|
38
|
+
sarib query notes.sarib --spec '{"select":"none","filter":{"type":"task"}}'
|
|
39
|
+
sarib apply notes.sarib --op '{"kind":"set-property","target":"t1","args":{"key":"status","value":"done"}}'
|
|
40
|
+
sarib canon notes.sarib # canonical normal form (for hash/diff)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## MCP server
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
sarib-mcp /path/to/folder-of-.sarib-files
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Exposes `sarib_query / sarib_apply / sarib_render / sarib_validate / sarib_canon`
|
|
50
|
+
to any MCP client (Claude Desktop, Claude Code, Cursor, …).
|
|
51
|
+
|
|
52
|
+
## Status
|
|
53
|
+
|
|
54
|
+
Research-grade **v0.1**. Spec, full design history, decision log, and benchmarks:
|
|
55
|
+
<https://github.com/SyedSaribSultan/sarib-lang>. License: MIT.
|
sarib-0.1.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# sarib
|
|
2
|
+
|
|
3
|
+
Reference implementation of the **.sarib** knowledge language — a plain-text format
|
|
4
|
+
that is at once a readable document (a Markdown superset) and a labeled property graph.
|
|
5
|
+
Agents query bounded subgraphs and apply id-addressed atomic edits instead of
|
|
6
|
+
re-reading or regenerating whole files.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install sarib # core, zero dependencies
|
|
10
|
+
pip install "sarib[mcp]" # + the MCP server for agent clients
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## CLI
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
sarib validate notes.sarib
|
|
17
|
+
sarib render notes.sarib --view outline # document | outline | board | mermaid
|
|
18
|
+
sarib query notes.sarib --spec '{"select":"none","filter":{"type":"task"}}'
|
|
19
|
+
sarib apply notes.sarib --op '{"kind":"set-property","target":"t1","args":{"key":"status","value":"done"}}'
|
|
20
|
+
sarib canon notes.sarib # canonical normal form (for hash/diff)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## MCP server
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
sarib-mcp /path/to/folder-of-.sarib-files
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Exposes `sarib_query / sarib_apply / sarib_render / sarib_validate / sarib_canon`
|
|
30
|
+
to any MCP client (Claude Desktop, Claude Code, Cursor, …).
|
|
31
|
+
|
|
32
|
+
## Status
|
|
33
|
+
|
|
34
|
+
Research-grade **v0.1**. Spec, full design history, decision log, and benchmarks:
|
|
35
|
+
<https://github.com/SyedSaribSultan/sarib-lang>. License: MIT.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sarib"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Reference implementation of the .sarib knowledge language: query bounded subgraphs and apply id-addressed atomic edits over plain-text knowledge."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Syed Sarib Sultan" }]
|
|
13
|
+
keywords = ["knowledge", "graph", "markdown", "mcp", "ai-agents", "llm", "notes", "property-graph"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Text Processing :: Markup",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
mcp = ["mcp>=1.0"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
sarib = "sarib.cli:main"
|
|
28
|
+
sarib-mcp = "sarib.mcp_server:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/SyedSaribSultan/sarib-lang"
|
|
32
|
+
Repository = "https://github.com/SyedSaribSultan/sarib-lang"
|
|
33
|
+
Issues = "https://github.com/SyedSaribSultan/sarib-lang/issues"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
packages = ["sarib"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""sarib — reference implementation of the .sarib knowledge language (v0.1).
|
|
2
|
+
Components per Stage 13: parser, canon(normalizer), model store, ops, query, render.
|
|
3
|
+
"""
|
|
4
|
+
from .parser import parse
|
|
5
|
+
from .canon import canon
|
|
6
|
+
from .render import fmt, VIEWS
|
|
7
|
+
from .ops import apply, fold, OpRejected
|
|
8
|
+
from .query import query
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""sarib.canon — the canonical normal form (Stage 9, D-041).
|
|
2
|
+
Line-oriented canonical JSON: one record per line, document-order nodes,
|
|
3
|
+
sorted keys, canonical scalars. Exactly one byte-string per model state.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import json
|
|
7
|
+
from .model import Doc
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _clean(d: dict) -> dict:
|
|
11
|
+
return {k: v for k, v in d.items() if v not in (None, "", {}, []) and not k.startswith("_")}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def canon(doc: Doc) -> str:
|
|
15
|
+
out = []
|
|
16
|
+
header = {"sarib": doc.meta.get("sarib", "0.1")}
|
|
17
|
+
for k in sorted(doc.meta):
|
|
18
|
+
if k != "sarib":
|
|
19
|
+
header[k] = doc.meta[k]
|
|
20
|
+
out.append(json.dumps(header, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
|
21
|
+
for n in doc.walk(None): # document order (invariant 10)
|
|
22
|
+
rec = _clean({
|
|
23
|
+
"id": n.id, "kind": "node", "type": n.type, "hint": n.kind_hint,
|
|
24
|
+
"title": n.title, "content": n.content, "slug": n.slug,
|
|
25
|
+
"props": {k: v for k, v in sorted(n.properties.items())
|
|
26
|
+
if not k.startswith("_")
|
|
27
|
+
and not (isinstance(v, str) and v.startswith("[[") and v.endswith("]]"))},
|
|
28
|
+
"parent": n.parent, "order": n.order,
|
|
29
|
+
"status": None if n.status == "active" else n.status,
|
|
30
|
+
"provenance": n.provenance,
|
|
31
|
+
})
|
|
32
|
+
out.append(json.dumps(rec, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
|
33
|
+
for eid in sorted(doc.edges): # tie-break cascade (D-029)
|
|
34
|
+
e = doc.edges[eid]
|
|
35
|
+
rec = _clean({
|
|
36
|
+
"id": e.id, "kind": "edge", "type": e.type, "source": e.source,
|
|
37
|
+
"target": e.target, "family": None if e.family == "crossref" else e.family,
|
|
38
|
+
"props": dict(sorted(e.properties.items())) or None,
|
|
39
|
+
"status": None if e.status == "active" else e.status,
|
|
40
|
+
})
|
|
41
|
+
out.append(json.dumps(rec, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
|
42
|
+
return "\n".join(out) + "\n"
|
sarib-0.1.0/sarib/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""sarib CLI (Stage 13 D-057): parse | validate | canon | fmt | query | apply | render"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse, json, sys, pathlib
|
|
4
|
+
|
|
5
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
|
6
|
+
from sarib import parse, canon, fmt, VIEWS, apply as apply_op, query as run_query
|
|
7
|
+
from sarib.ops import OpRejected
|
|
8
|
+
from sarib.importer import build as import_build, DEFAULT_VOCAB
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load(path):
|
|
12
|
+
return parse(pathlib.Path(path).read_text(encoding="utf-8"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
17
|
+
sys.stdout.reconfigure(encoding="utf-8") # piped output on Windows defaults to cp1252
|
|
18
|
+
ap = argparse.ArgumentParser(prog="sarib", description=".sarib reference CLI v0.1")
|
|
19
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
20
|
+
for c in ("parse", "validate", "canon", "fmt"):
|
|
21
|
+
sub.add_parser(c).add_argument("file")
|
|
22
|
+
q = sub.add_parser("query"); q.add_argument("file"); q.add_argument("--spec", required=True)
|
|
23
|
+
a = sub.add_parser("apply"); a.add_argument("file"); a.add_argument("--op", required=True); a.add_argument("--write", action="store_true")
|
|
24
|
+
r = sub.add_parser("render"); r.add_argument("file"); r.add_argument("--view", default="outline", choices=list(VIEWS))
|
|
25
|
+
im = sub.add_parser("import", help="markdown/prose -> a .sarib knowledge graph")
|
|
26
|
+
im.add_argument("inputs", nargs="+")
|
|
27
|
+
im.add_argument("-o", "--out")
|
|
28
|
+
im.add_argument("--title", default=None, help="graph title; defaults to the source's own front-matter title")
|
|
29
|
+
im.add_argument("--extract-edges", action="store_true", help="add constrained+verified LLM edges")
|
|
30
|
+
im.add_argument("--model", default="qwen2.5:7b")
|
|
31
|
+
im.add_argument("--endpoint", default="http://localhost:11434/api/chat")
|
|
32
|
+
im.add_argument("--vocab", default=",".join(DEFAULT_VOCAB), help="closed edge-type list, comma-separated")
|
|
33
|
+
ns = ap.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
if ns.cmd == "import":
|
|
36
|
+
inputs = [(pathlib.Path(p).stem, pathlib.Path(p).read_text(encoding="utf-8")) for p in ns.inputs]
|
|
37
|
+
text, stats, diags = import_build(
|
|
38
|
+
inputs, title=ns.title, extract=ns.extract_edges, model=ns.model,
|
|
39
|
+
endpoint=ns.endpoint, vocab=[v.strip() for v in ns.vocab.split(",") if v.strip()])
|
|
40
|
+
if ns.out:
|
|
41
|
+
pathlib.Path(ns.out).write_text(text, encoding="utf-8")
|
|
42
|
+
print(f"wrote {ns.out}")
|
|
43
|
+
print("stats: " + json.dumps(stats))
|
|
44
|
+
hard = [d for d in diags if not d.startswith(("unresolved-reference", "ambiguous-reference"))]
|
|
45
|
+
for d in hard:
|
|
46
|
+
print(f"WARN: {d}")
|
|
47
|
+
else:
|
|
48
|
+
print(text, end="")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
doc = _load(ns.file)
|
|
52
|
+
if ns.cmd == "parse":
|
|
53
|
+
print(canon(doc), end="")
|
|
54
|
+
elif ns.cmd == "canon":
|
|
55
|
+
print(canon(doc), end="")
|
|
56
|
+
elif ns.cmd == "validate":
|
|
57
|
+
for d in doc.diagnostics:
|
|
58
|
+
print(f"lint: {d}")
|
|
59
|
+
print(f"OK: {len(doc.nodes)} nodes, {len(doc.edges)} edges, "
|
|
60
|
+
f"{len(doc.diagnostics)} diagnostics (non-fatal, D-049)")
|
|
61
|
+
elif ns.cmd == "fmt":
|
|
62
|
+
print(fmt(doc), end="")
|
|
63
|
+
elif ns.cmd == "query":
|
|
64
|
+
print(json.dumps(run_query(doc, json.loads(ns.spec)), indent=1, ensure_ascii=False))
|
|
65
|
+
elif ns.cmd == "apply":
|
|
66
|
+
try:
|
|
67
|
+
apply_op(doc, json.loads(ns.op))
|
|
68
|
+
except OpRejected as e:
|
|
69
|
+
print(f"REJECTED: {e}"); sys.exit(1)
|
|
70
|
+
out = fmt(doc)
|
|
71
|
+
if ns.write:
|
|
72
|
+
pathlib.Path(ns.file).write_text(out, encoding="utf-8")
|
|
73
|
+
print(f"applied + written to {ns.file}")
|
|
74
|
+
else:
|
|
75
|
+
print(out, end="")
|
|
76
|
+
elif ns.cmd == "render":
|
|
77
|
+
print(VIEWS[ns.view](doc), end="")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""sarib.importer — prose/markdown -> a .sarib knowledge graph. The adoption on-ramp.
|
|
2
|
+
|
|
3
|
+
A CONSUMER of the model (like tools/preview.py and the MCP server); it lives OUTSIDE the
|
|
4
|
+
<=1000 LOC parser core (G4), so it does not affect that budget.
|
|
5
|
+
|
|
6
|
+
Two layers, both emitting valid, round-trippable .sarib:
|
|
7
|
+
|
|
8
|
+
skeleton (default) deterministic. Markdown headings/list-items/paragraphs -> nodes with
|
|
9
|
+
stable slug ids + the containment tree; existing [[wikilinks]] and
|
|
10
|
+
[rel:: [[x]]] refs are preserved by the parser. No LLM, no network.
|
|
11
|
+
|
|
12
|
+
--extract-edges constrained + verified edge extraction (market/importer-extraction-
|
|
13
|
+
research.md). Boxed so a model can only SELECT, never invent:
|
|
14
|
+
L1 closed vocab + existing-slug targets (JSON-schema enum)
|
|
15
|
+
L2 constrained decoding (Ollama `format`=schema)
|
|
16
|
+
L3 span-grounding (quote must be verbatim)
|
|
17
|
+
L4 entailment verify (independent yes/no per edge)
|
|
18
|
+
L5 abstain (unsupported/ambiguous dropped)
|
|
19
|
+
Kept edges are written to the surface as `[rel:: [[#slug]]]` with an
|
|
20
|
+
`inferred` provenance comment (D-019); resolution never guesses (D-024).
|
|
21
|
+
|
|
22
|
+
Known limitations (documented, not bugs): edges are read from a node's own paragraph text, so
|
|
23
|
+
relationships stated only inside sub-bullets or deeper sub-sections are missed (conservative:
|
|
24
|
+
fewer edges, never wrong). Obsidian-style block ids (`^id`) on a heading are not preserved
|
|
25
|
+
(surface refs use slugs). Import each file singly for maximum fidelity.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
import json, re, urllib.request
|
|
29
|
+
from .parser import parse
|
|
30
|
+
from .model import anchor_owner
|
|
31
|
+
|
|
32
|
+
DEFAULT_VOCAB = ["depends-on", "refines", "supersedes", "cites", "part-of"]
|
|
33
|
+
VOCAB_DEF = {
|
|
34
|
+
"depends-on": "the source cannot proceed / is blocked until the target.",
|
|
35
|
+
"refines": "the source clarifies, extends, or amends the target.",
|
|
36
|
+
"supersedes": "the source replaces or overrides the target.",
|
|
37
|
+
"cites": "the source references the target as evidence, basis, or grounding.",
|
|
38
|
+
"part-of": "the source is a component or sub-part of the target.",
|
|
39
|
+
}
|
|
40
|
+
PHRASE = {
|
|
41
|
+
"depends-on": "depends on (is blocked until) ",
|
|
42
|
+
"refines": "refines or amends ",
|
|
43
|
+
"supersedes": "replaces or supersedes ",
|
|
44
|
+
"cites": "cites as evidence ",
|
|
45
|
+
"part-of": "is part of ",
|
|
46
|
+
}
|
|
47
|
+
_TYPE_RE = re.compile(r"^[A-Za-z][\w-]*$") # a rel type must survive the parser's TYPED_REF_RE
|
|
48
|
+
OLLAMA = "http://localhost:11434/api/chat"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------- deterministic skeleton ----------
|
|
52
|
+
|
|
53
|
+
def _slug_base(title):
|
|
54
|
+
s = re.sub(r"[^a-z0-9]+", "-", (title or "").lower()).strip("-") or "node"
|
|
55
|
+
return s[:48].rstrip("-") or "node"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dedup(base, used):
|
|
59
|
+
s, i = base, 2
|
|
60
|
+
while s in used:
|
|
61
|
+
s = f"{base}-{i}"; i += 1
|
|
62
|
+
used.add(s)
|
|
63
|
+
return s
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _strip_front_matter(text):
|
|
67
|
+
lines = text.splitlines()
|
|
68
|
+
if lines and lines[0].strip() == "---":
|
|
69
|
+
j = 1
|
|
70
|
+
while j < len(lines) and lines[j].strip() != "---":
|
|
71
|
+
j += 1
|
|
72
|
+
return "\n".join(lines[j + 1:]).lstrip("\n")
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _bump_headings(text):
|
|
77
|
+
return re.sub(r"(?m)^(#{1,5})(\s)", r"#\1\2", text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _combine(inputs):
|
|
81
|
+
"""One file -> as-is (parser lifts its front matter). Many -> each nested under a file
|
|
82
|
+
heading, with its own front matter stripped (else it would become body prose)."""
|
|
83
|
+
if len(inputs) == 1:
|
|
84
|
+
return inputs[0][1]
|
|
85
|
+
return "\n\n".join(f"# {name}\n\n" + _bump_headings(_strip_front_matter(text))
|
|
86
|
+
for name, text in inputs)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _assign_slugs(doc):
|
|
90
|
+
"""Every titled node gets a UNIQUE slug. Explicit slugs (from `{#id}`) are de-duped too
|
|
91
|
+
(bug fix: a document-authored slug could otherwise collide with an auto one)."""
|
|
92
|
+
used, slug_of = set(), {}
|
|
93
|
+
for n in doc.walk(None):
|
|
94
|
+
if n.title:
|
|
95
|
+
n.slug = _dedup(n.slug or _slug_base(n.title), used)
|
|
96
|
+
slug_of[n.id] = n.slug
|
|
97
|
+
return slug_of
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _section_text(doc, node):
|
|
101
|
+
"""The node's own prose (its title + direct prose children) — what edges are read from."""
|
|
102
|
+
body = [c.content for c in doc.children(node.id) if c.kind_hint == "prose"]
|
|
103
|
+
return (node.title + "\n" + "\n".join(body)).strip()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------- constrained + verified extraction ----------
|
|
107
|
+
|
|
108
|
+
def _ollama(endpoint, model, system, user, schema, timeout=120):
|
|
109
|
+
body = json.dumps({
|
|
110
|
+
"model": model,
|
|
111
|
+
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
|
|
112
|
+
"stream": False, "format": schema, "options": {"temperature": 0, "seed": 42},
|
|
113
|
+
}).encode()
|
|
114
|
+
req = urllib.request.Request(endpoint, data=body, headers={"Content-Type": "application/json"})
|
|
115
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
116
|
+
return json.loads(json.load(r)["message"]["content"])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _norm(s):
|
|
120
|
+
return re.sub(r"\s+", " ", s or "").strip().lower()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _extract_schema(vocab, slugs):
|
|
124
|
+
return {"type": "object", "properties": {"edges": {"type": "array", "items": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {"type": {"type": "string", "enum": vocab},
|
|
127
|
+
"target": {"type": "string", "enum": slugs},
|
|
128
|
+
"span": {"type": "string"}},
|
|
129
|
+
"required": ["type", "target", "span"]}}}, "required": ["edges"]}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_VERIFY_SCHEMA = {"type": "object",
|
|
133
|
+
"properties": {"answer": {"type": "string", "enum": ["yes", "no"]}},
|
|
134
|
+
"required": ["answer"]}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _existing_edges(doc):
|
|
138
|
+
"""(src-slug, type, tgt-slug) already present from the surface — for de-dup (#8)."""
|
|
139
|
+
seen = set()
|
|
140
|
+
for e in doc.edges.values():
|
|
141
|
+
if e.status != "active" or e.target.startswith("?"):
|
|
142
|
+
continue
|
|
143
|
+
s = doc.nodes.get(anchor_owner(doc, e.source))
|
|
144
|
+
t = doc.nodes.get(anchor_owner(doc, e.target))
|
|
145
|
+
if s and t and s.slug and t.slug:
|
|
146
|
+
seen.add((s.slug, e.type, t.slug))
|
|
147
|
+
return seen
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _propose(doc, model, endpoint, vocab):
|
|
151
|
+
"""One constrained call per titled heading node. Returns (kept, stats). kept: src_id->[edge]."""
|
|
152
|
+
titled = [n for n in doc.walk(None) if n.title and n.kind_hint == "heading"]
|
|
153
|
+
id_of = {n.slug: n.id for n in titled}
|
|
154
|
+
slugs = [n.slug for n in titled]
|
|
155
|
+
already = _existing_edges(doc)
|
|
156
|
+
vdef = "\n".join(f"- {k}: {v}" for k, v in VOCAB_DEF.items() if k in vocab)
|
|
157
|
+
directory = "\n".join(f"{n.slug}: {n.title}" for n in titled)
|
|
158
|
+
system = (
|
|
159
|
+
"You are a STRICT relationship extractor. Output ONLY relationships EXPLICITLY stated "
|
|
160
|
+
"in the given text. Do NOT infer, guess, or use outside knowledge. When in doubt, leave "
|
|
161
|
+
"it out.\n\nAllowed types (use EXACTLY one):\n" + vdef + "\n\n'target' must be the slug "
|
|
162
|
+
"of the OTHER node being referred to. 'span' MUST be the exact verbatim substring of the "
|
|
163
|
+
"text that states the relationship. If the text states no relationship to another node, "
|
|
164
|
+
"return an empty list.")
|
|
165
|
+
stats = {"proposed": 0, "reject_target": 0, "reject_span": 0, "verify_no": 0, "dup": 0, "kept": 0}
|
|
166
|
+
kept = {}
|
|
167
|
+
for n in titled:
|
|
168
|
+
text = _section_text(doc, n)
|
|
169
|
+
others = [s for s in slugs if s != n.slug]
|
|
170
|
+
if not others:
|
|
171
|
+
continue
|
|
172
|
+
user = (f"Current node: {n.slug} — {n.title}\n\nDirectory of other nodes (slug: title):\n"
|
|
173
|
+
f"{directory}\n\nText of the current node:\n{text}\n\n"
|
|
174
|
+
"Return JSON {\"edges\":[{\"type\":..,\"target\":\"<slug>\",\"span\":\"..\"}]}.")
|
|
175
|
+
try:
|
|
176
|
+
res = _ollama(endpoint, model, system, user, _extract_schema(vocab, others))
|
|
177
|
+
except Exception:
|
|
178
|
+
stats["errors"] = stats.get("errors", 0) + 1
|
|
179
|
+
continue
|
|
180
|
+
for e in res.get("edges", []):
|
|
181
|
+
stats["proposed"] += 1
|
|
182
|
+
rel, tgt, span = e.get("type"), e.get("target"), e.get("span", "")
|
|
183
|
+
if rel not in vocab or tgt not in id_of or tgt == n.slug:
|
|
184
|
+
stats["reject_target"] += 1; continue
|
|
185
|
+
if _norm(span) not in _norm(text): # L3 span grounding
|
|
186
|
+
stats["reject_span"] += 1; continue
|
|
187
|
+
if (n.slug, rel, tgt) in already: # #8 de-dup vs surface
|
|
188
|
+
stats["dup"] += 1; continue
|
|
189
|
+
if not _verify(model, endpoint, n.title, rel, doc.nodes[id_of[tgt]].title, span): # L4
|
|
190
|
+
stats["verify_no"] += 1; continue
|
|
191
|
+
kept.setdefault(n.id, []).append({"type": rel, "target_slug": tgt, "span": span})
|
|
192
|
+
already.add((n.slug, rel, tgt)) # de-dup within run too
|
|
193
|
+
stats["kept"] += 1
|
|
194
|
+
return kept, stats
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _verify(model, endpoint, src_title, rel, tgt_title, span):
|
|
198
|
+
"""L4: independent entailment check. Few-shot + natural-language claim so a weak model
|
|
199
|
+
actually discriminates (a terse yes/no enum degenerates to 'no' on everything)."""
|
|
200
|
+
system = (
|
|
201
|
+
"Decide whether the SPAN supports the CLAIM. Reply yes if the span states or clearly "
|
|
202
|
+
"implies the claim (in that direction); reply no otherwise.\n"
|
|
203
|
+
"Example 1 SPAN: \"A is blocked until we finish B.\" CLAIM: A depends on B. -> yes\n"
|
|
204
|
+
"Example 2 SPAN: \"B was amended by A.\" CLAIM: A refines B. -> yes\n"
|
|
205
|
+
"Example 3 SPAN: \"B was amended by A.\" CLAIM: A supersedes B. -> no\n"
|
|
206
|
+
"Example 4 SPAN: \"We should water the plants.\" CLAIM: A depends on B. -> no")
|
|
207
|
+
user = (f"SPAN: \"{span}\"\nCLAIM: {src_title} {PHRASE.get(rel, rel + ' ')}{tgt_title}.\n"
|
|
208
|
+
"Answer yes or no.")
|
|
209
|
+
try:
|
|
210
|
+
return _ollama(endpoint, model, system, user, _VERIFY_SCHEMA).get("answer") == "yes"
|
|
211
|
+
except Exception:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------- emit ----------
|
|
216
|
+
|
|
217
|
+
def _emit(doc, kept, title, extra_meta):
|
|
218
|
+
out = ["---", "sarib: 0.1", "vocab: std@0.1", f"title: {title}"]
|
|
219
|
+
for k, v in extra_meta.items(): # #1: carry source front matter through
|
|
220
|
+
out.append(f"{k}: {v}")
|
|
221
|
+
out += ["---",
|
|
222
|
+
"<!-- generated by `sarib import`; edges marked `inferred` were auto-extracted "
|
|
223
|
+
"and verified (see provenance comments). -->", ""]
|
|
224
|
+
for n in doc.walk(None):
|
|
225
|
+
if n.kind_hint == "heading":
|
|
226
|
+
level = int(n.properties.get("_level", 1))
|
|
227
|
+
attrs = ([f".{n.type}"] if n.type else []) + ([f"#{n.slug}"] if n.slug else [])
|
|
228
|
+
mark = (" {" + " ".join(attrs) + "}") if attrs else ""
|
|
229
|
+
out.append(f"{'#' * level} {n.title}{mark}")
|
|
230
|
+
for k, v in n.properties.items():
|
|
231
|
+
if not k.startswith("_"):
|
|
232
|
+
out.append(f"{k}:: {v}")
|
|
233
|
+
for e in kept.get(n.id, []):
|
|
234
|
+
out.append(f"<!-- inferred: \"{e['span'][:80].strip()}\" -->")
|
|
235
|
+
out.append(f"[{e['type']}:: [[#{e['target_slug']}]]]")
|
|
236
|
+
out.append("")
|
|
237
|
+
elif n.kind_hint == "item":
|
|
238
|
+
attrs = ([f".{n.type}"] if n.type else []) + ([f"#{n.slug}"] if n.slug else [])
|
|
239
|
+
mark = (" {" + " ".join(attrs) + "}") if attrs else ""
|
|
240
|
+
out.append(f"- {n.title}{mark}")
|
|
241
|
+
else:
|
|
242
|
+
out.append(n.content + "\n")
|
|
243
|
+
return "\n".join(out).rstrip() + "\n"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------- structural round-trip verification (#4) ----------
|
|
247
|
+
|
|
248
|
+
def _pmap(d):
|
|
249
|
+
"""slug -> parent-slug (or None), over titled nodes: the containment shape."""
|
|
250
|
+
m = {}
|
|
251
|
+
for n in d.walk(None):
|
|
252
|
+
if n.title:
|
|
253
|
+
p = d.nodes.get(n.parent) if n.parent else None
|
|
254
|
+
m[n.slug] = p.slug if (p and p.title) else None
|
|
255
|
+
return m
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------- entry point ----------
|
|
259
|
+
|
|
260
|
+
def build(inputs, title=None, extract=False,
|
|
261
|
+
model="qwen2.5:7b", endpoint=OLLAMA, vocab=None):
|
|
262
|
+
"""inputs: [(name, text)]. Returns (sarib_text, stats, diagnostics)."""
|
|
263
|
+
vocab = vocab or list(DEFAULT_VOCAB)
|
|
264
|
+
warns = [f"ignored invalid edge-type '{v}' (must match {_TYPE_RE.pattern})"
|
|
265
|
+
for v in vocab if not _TYPE_RE.match(v)] # #7 vocab guard
|
|
266
|
+
vocab = [v for v in vocab if _TYPE_RE.match(v)]
|
|
267
|
+
|
|
268
|
+
if len(inputs) > 1: # #3 level-6 warning
|
|
269
|
+
for name, txt in inputs:
|
|
270
|
+
if re.search(r"(?m)^######\s", _strip_front_matter(txt)):
|
|
271
|
+
warns.append(f"multi-file: '{name}' has level-6 headings; deep nesting may "
|
|
272
|
+
"flatten — import it singly for full fidelity")
|
|
273
|
+
|
|
274
|
+
doc = parse(_combine(inputs))
|
|
275
|
+
_assign_slugs(doc)
|
|
276
|
+
# #1: prefer an explicit --title, else the source doc's own title, else a default
|
|
277
|
+
eff_title = title or doc.meta.get("title") or "Imported knowledge"
|
|
278
|
+
extra_meta = {k: v for k, v in doc.meta.items() if k not in ("sarib", "vocab", "title")}
|
|
279
|
+
|
|
280
|
+
kept, stats = ({}, {})
|
|
281
|
+
if extract:
|
|
282
|
+
if not vocab:
|
|
283
|
+
warns.append("no valid edge types; skipping extraction")
|
|
284
|
+
else:
|
|
285
|
+
kept, stats = _propose(doc, model, endpoint, vocab)
|
|
286
|
+
|
|
287
|
+
text = _emit(doc, kept, eff_title, extra_meta)
|
|
288
|
+
|
|
289
|
+
check = parse(text) # #4 structural check
|
|
290
|
+
problems = list(warns)
|
|
291
|
+
di, ci = _pmap(doc), _pmap(check)
|
|
292
|
+
if set(di) != set(ci):
|
|
293
|
+
problems.append(f"structure-drift: {len(di)} titled nodes intended, {len(ci)} after round-trip")
|
|
294
|
+
elif di != ci:
|
|
295
|
+
problems.append("structure-drift: nesting changed on round-trip")
|
|
296
|
+
live_slugs = {n.slug for n in check.walk(None) if n.title}
|
|
297
|
+
for lst in kept.values():
|
|
298
|
+
for e in lst:
|
|
299
|
+
if e["target_slug"] not in live_slugs:
|
|
300
|
+
problems.append(f"inferred edge target #{e['target_slug']} did not resolve")
|
|
301
|
+
unresolved = [d for d in check.diagnostics if d.startswith("unresolved-reference")]
|
|
302
|
+
stats.update(nodes=len([n for n in doc.nodes.values() if n.title]),
|
|
303
|
+
edges=sum(len(v) for v in kept.values()),
|
|
304
|
+
unresolved=len(unresolved), problems=len(problems))
|
|
305
|
+
return text, stats, problems + check.diagnostics
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""sarib MCP server (Stage 13 §4, D-057) — the day-one consumer.
|
|
2
|
+
Exposes .sarib files to any MCP-speaking agent: bounded queries (never whole-file dumps),
|
|
3
|
+
id-addressed atomic ops (never regeneration), projections, and validation.
|
|
4
|
+
|
|
5
|
+
Run: python -m sarib.mcp_server /path/to/knowledge/dir
|
|
6
|
+
Claude Desktop / Cowork config:
|
|
7
|
+
{ "mcpServers": { "sarib": { "command": "python",
|
|
8
|
+
"args": ["-m", "sarib.mcp_server", "<dir>"], "cwd": "<repo>/impl" } } }
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import json, pathlib, sys
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
|
14
|
+
from mcp.server.fastmcp import FastMCP
|
|
15
|
+
from sarib import parse, canon, fmt, query as run_query
|
|
16
|
+
from sarib.ops import apply as apply_op, OpRejected
|
|
17
|
+
from sarib.render import VIEWS
|
|
18
|
+
|
|
19
|
+
ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
|
|
20
|
+
mcp = FastMCP("sarib")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _doc(file: str):
|
|
24
|
+
p = (ROOT / file).resolve()
|
|
25
|
+
assert str(p).startswith(str(ROOT)), "path escape"
|
|
26
|
+
return p, parse(p.read_text(encoding="utf-8"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool()
|
|
30
|
+
def sarib_query(file: str, spec: str) -> str:
|
|
31
|
+
"""Run a bounded 7-axis query over a .sarib file. spec is JSON:
|
|
32
|
+
{start, select, direction, order, filter:{type,status,prop:[[k,op,v]]},
|
|
33
|
+
bound:{max_nodes,max_depth}, projection:[...]}. Returns a result subgraph
|
|
34
|
+
carrying stable ids — use those ids as op targets (the read/write bridge)."""
|
|
35
|
+
_, doc = _doc(file)
|
|
36
|
+
return json.dumps(run_query(doc, json.loads(spec)), ensure_ascii=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool()
|
|
40
|
+
def sarib_apply(file: str, op: str) -> str:
|
|
41
|
+
"""Apply one atomic operation (JSON: {kind, target, args, expect?}) addressed
|
|
42
|
+
by node/edge id. Guarded ops (expect:{id:{version:v}}) are rejected if stale.
|
|
43
|
+
Writes the updated surface back to the file. Costs a delta, not a regeneration."""
|
|
44
|
+
p, doc = _doc(file)
|
|
45
|
+
try:
|
|
46
|
+
apply_op(doc, json.loads(op))
|
|
47
|
+
except OpRejected as e:
|
|
48
|
+
return f"REJECTED: {e}"
|
|
49
|
+
p.write_text(fmt(doc), encoding="utf-8")
|
|
50
|
+
return "applied"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def sarib_render(file: str, view: str = "outline") -> str:
|
|
55
|
+
"""Project a .sarib file into a view: document | outline (spatial cues) |
|
|
56
|
+
board (tasks by status) | mermaid (dependency graph, terminal export)."""
|
|
57
|
+
_, doc = _doc(file)
|
|
58
|
+
return VIEWS[view](doc)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool()
|
|
62
|
+
def sarib_validate(file: str) -> str:
|
|
63
|
+
"""Three-tier validation (D-049): parse is total; returns non-fatal diagnostics."""
|
|
64
|
+
_, doc = _doc(file)
|
|
65
|
+
return json.dumps({"nodes": len(doc.nodes), "edges": len(doc.edges),
|
|
66
|
+
"diagnostics": doc.diagnostics}, ensure_ascii=False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
def sarib_canon(file: str) -> str:
|
|
71
|
+
"""Canonical normal form (D-041): one byte-string per state; for hashing/diff."""
|
|
72
|
+
_, doc = _doc(file)
|
|
73
|
+
return canon(doc)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main():
|
|
77
|
+
"""Console-script entry point (`sarib-mcp <folder>`); folder read from argv at import."""
|
|
78
|
+
mcp.run()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|