sarib 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.
- sarib/__init__.py +10 -0
- sarib/canon.py +42 -0
- sarib/cli.py +81 -0
- sarib/importer.py +305 -0
- sarib/mcp_server.py +82 -0
- sarib/model.py +101 -0
- sarib/ops.py +110 -0
- sarib/parser.py +202 -0
- sarib/query.py +99 -0
- sarib/render.py +99 -0
- sarib-0.1.0.dist-info/METADATA +55 -0
- sarib-0.1.0.dist-info/RECORD +15 -0
- sarib-0.1.0.dist-info/WHEEL +5 -0
- sarib-0.1.0.dist-info/entry_points.txt +3 -0
- sarib-0.1.0.dist-info/top_level.txt +1 -0
sarib/__init__.py
ADDED
|
@@ -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"
|
sarib/canon.py
ADDED
|
@@ -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/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()
|
sarib/importer.py
ADDED
|
@@ -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
|
sarib/mcp_server.py
ADDED
|
@@ -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()
|
sarib/model.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""sarib.model — the core object model (Stage 4). Nodes, edges, invariants."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Node:
|
|
9
|
+
id: str
|
|
10
|
+
type: Optional[str] = None # None = untyped prose (L0)
|
|
11
|
+
kind_hint: str = "prose" # heading | item | prose | document (surface fidelity only)
|
|
12
|
+
title: str = "" # heading/item line text (sans marks); "" for prose
|
|
13
|
+
content: str = "" # raw inline text (prose body); round-trip faithful
|
|
14
|
+
slug: Optional[str] = None
|
|
15
|
+
properties: dict = field(default_factory=dict)
|
|
16
|
+
status: str = "active" # active | retracted (P12)
|
|
17
|
+
provenance: Optional[str] = None # None = asserted-by-owner (D-019)
|
|
18
|
+
parent: Optional[str] = None # the single home containment edge (D-016)
|
|
19
|
+
order: int = 0 # sibling order (invariant 5)
|
|
20
|
+
version: int = 0 # bumped per op; expect-precondition target (D-038)
|
|
21
|
+
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
return self.title or (self.content[:40] if self.content else self.id)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Edge:
|
|
28
|
+
id: str
|
|
29
|
+
type: str
|
|
30
|
+
source: str
|
|
31
|
+
target: str # node id, or "?unresolved:<name>" (D-024: never guessed)
|
|
32
|
+
family: str = "crossref"
|
|
33
|
+
properties: dict = field(default_factory=dict)
|
|
34
|
+
status: str = "active"
|
|
35
|
+
provenance: Optional[str] = None
|
|
36
|
+
version: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Doc:
|
|
41
|
+
meta: dict = field(default_factory=dict) # front matter (vocab pin, title, ...)
|
|
42
|
+
nodes: dict = field(default_factory=dict) # id -> Node (insertion = document order)
|
|
43
|
+
edges: dict = field(default_factory=dict) # id -> Edge
|
|
44
|
+
diagnostics: list = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
# -- containment helpers (the spanning tree, D-016) --
|
|
47
|
+
def children(self, nid: Optional[str]) -> list:
|
|
48
|
+
kids = [n for n in self.nodes.values() if n.parent == nid and n.status == "active"]
|
|
49
|
+
return sorted(kids, key=lambda n: (n.order, n.id))
|
|
50
|
+
|
|
51
|
+
def walk(self, nid: Optional[str] = None):
|
|
52
|
+
"""inorder(N, E_c): DFS pre-order by sibling order — THE document (Stage 4 §6)."""
|
|
53
|
+
for c in self.children(nid):
|
|
54
|
+
yield c
|
|
55
|
+
yield from self.walk(c.id)
|
|
56
|
+
|
|
57
|
+
def node_by_slug(self, slug: str) -> Optional[Node]:
|
|
58
|
+
for n in self.nodes.values():
|
|
59
|
+
if n.slug == slug and n.status == "active":
|
|
60
|
+
return n
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# -- Tier-1 validation: the 10 invariants (Stage 4 §11) --
|
|
64
|
+
def check_invariants(self) -> list:
|
|
65
|
+
diags = []
|
|
66
|
+
seen_slugs = {}
|
|
67
|
+
for n in self.nodes.values():
|
|
68
|
+
if n.parent is not None and n.parent not in self.nodes:
|
|
69
|
+
diags.append(f"invariant2: node {n.id} has missing parent {n.parent}")
|
|
70
|
+
if n.slug:
|
|
71
|
+
if n.slug in seen_slugs:
|
|
72
|
+
diags.append(f"duplicate-slug: #{n.slug} on {seen_slugs[n.slug]} and {n.id}")
|
|
73
|
+
seen_slugs[n.slug] = n.id
|
|
74
|
+
# cycle check on containment (must be a tree)
|
|
75
|
+
for n in self.nodes.values():
|
|
76
|
+
hops, p = 0, n.parent
|
|
77
|
+
while p is not None and hops <= len(self.nodes):
|
|
78
|
+
p = self.nodes[p].parent if p in self.nodes else None
|
|
79
|
+
hops += 1
|
|
80
|
+
if hops > len(self.nodes):
|
|
81
|
+
diags.append(f"invariant2: containment cycle at {n.id}")
|
|
82
|
+
for e in self.edges.values():
|
|
83
|
+
if e.source not in self.nodes:
|
|
84
|
+
diags.append(f"invariant3: edge {e.id} dangling source {e.source}")
|
|
85
|
+
if not e.target.startswith("?unresolved:") and e.target not in self.nodes:
|
|
86
|
+
diags.append(f"invariant3: edge {e.id} dangling target {e.target}")
|
|
87
|
+
return diags
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def anchor_owner(doc: "Doc", nid: str) -> str:
|
|
91
|
+
"""Edges anchored in an untitled prose block bubble up to the nearest titled
|
|
92
|
+
ancestor for traversal/display (Stage 4 §5.3 anchor vs semantic endpoint)."""
|
|
93
|
+
n = doc.nodes.get(nid)
|
|
94
|
+
while n is not None and n.kind_hint == "prose" and n.parent:
|
|
95
|
+
n = doc.nodes.get(n.parent)
|
|
96
|
+
return n.id if n else nid
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_name(s: str) -> str:
|
|
100
|
+
import unicodedata
|
|
101
|
+
return " ".join(unicodedata.normalize("NFC", s).casefold().split())
|
sarib/ops.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""sarib.ops — the operation vocabulary (Stage 8, D-035..D-039).
|
|
2
|
+
Ops are data; targets are ids (never positions); expect = optimistic concurrency;
|
|
3
|
+
fold(op-set) is order-independent (ops sorted by Lamport ts -> LWW/grow-set semantics).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from .model import Doc, Node, Edge
|
|
7
|
+
|
|
8
|
+
PRIMITIVES = {"create-node", "retract-node", "set-content", "set-property",
|
|
9
|
+
"unset-property", "add-edge", "retract-edge", "move"}
|
|
10
|
+
COMPOSITES = {"merge", "tag"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpRejected(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_expect(doc: Doc, op: dict):
|
|
18
|
+
exp = op.get("expect")
|
|
19
|
+
if not exp:
|
|
20
|
+
return
|
|
21
|
+
for tid, cond in exp.items():
|
|
22
|
+
obj = doc.nodes.get(tid) or doc.edges.get(tid)
|
|
23
|
+
if obj is None:
|
|
24
|
+
raise OpRejected(f"expect: target {tid} not found")
|
|
25
|
+
if "version" in cond and obj.version != cond["version"]:
|
|
26
|
+
raise OpRejected(f"expect: {tid} at version {obj.version}, expected {cond['version']}")
|
|
27
|
+
for k, v in cond.get("props", {}).items():
|
|
28
|
+
if (obj.properties if hasattr(obj, "properties") else {}).get(k) != v:
|
|
29
|
+
raise OpRejected(f"expect: {tid}.{k} != {v!r}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def apply(doc: Doc, op: dict) -> Doc:
|
|
33
|
+
"""Apply one op, preserving the 10 invariants (D-035). Mutates and returns doc."""
|
|
34
|
+
_check_expect(doc, op)
|
|
35
|
+
kind, t, a = op["kind"], op.get("target"), op.get("args", {})
|
|
36
|
+
|
|
37
|
+
if kind == "create-node":
|
|
38
|
+
nid = a.get("id") or f"n{len(doc.nodes) + 1}x"
|
|
39
|
+
if nid in doc.nodes:
|
|
40
|
+
raise OpRejected(f"create-node: id {nid} exists")
|
|
41
|
+
parent = a.get("parent")
|
|
42
|
+
if parent is not None and parent not in doc.nodes:
|
|
43
|
+
raise OpRejected(f"create-node: parent {parent} missing") # invariant 2
|
|
44
|
+
n = Node(id=nid, type=a.get("type"), kind_hint=a.get("hint", "item"),
|
|
45
|
+
title=a.get("title", ""), content=a.get("content", ""),
|
|
46
|
+
properties=dict(a.get("props", {})), parent=parent,
|
|
47
|
+
order=a.get("order", len(doc.children(parent))),
|
|
48
|
+
provenance=op.get("provenance"))
|
|
49
|
+
doc.nodes[nid] = n
|
|
50
|
+
elif kind == "retract-node":
|
|
51
|
+
doc.nodes[t].status = "retracted" # P12: never destroy
|
|
52
|
+
elif kind == "set-content":
|
|
53
|
+
doc.nodes[t].content = a["content"]
|
|
54
|
+
elif kind == "set-property":
|
|
55
|
+
obj = doc.nodes.get(t) or doc.edges[t]
|
|
56
|
+
obj.properties[a["key"]] = a["value"]
|
|
57
|
+
elif kind == "unset-property":
|
|
58
|
+
obj = doc.nodes.get(t) or doc.edges[t]
|
|
59
|
+
obj.properties.pop(a["key"], None)
|
|
60
|
+
elif kind == "add-edge":
|
|
61
|
+
eid = a.get("id") or f"e{len(doc.edges) + 1}x"
|
|
62
|
+
if a.get("family") == "containment":
|
|
63
|
+
raise OpRejected("containment is edited only via create-node/move (D-035)")
|
|
64
|
+
for end in (a["source"], a["target"]):
|
|
65
|
+
if end not in doc.nodes:
|
|
66
|
+
raise OpRejected(f"add-edge: endpoint {end} missing") # invariant 3
|
|
67
|
+
doc.edges[eid] = Edge(id=eid, type=a["type"], source=a["source"],
|
|
68
|
+
target=a["target"], properties=dict(a.get("props", {})),
|
|
69
|
+
provenance=op.get("provenance"))
|
|
70
|
+
elif kind == "retract-edge":
|
|
71
|
+
doc.edges[t].status = "retracted"
|
|
72
|
+
elif kind == "move":
|
|
73
|
+
if a["parent"] not in doc.nodes:
|
|
74
|
+
raise OpRejected(f"move: parent {a['parent']} missing")
|
|
75
|
+
p = a["parent"] # cycle guard (invariant 2)
|
|
76
|
+
while p is not None:
|
|
77
|
+
if p == t:
|
|
78
|
+
raise OpRejected("move: would create containment cycle")
|
|
79
|
+
p = doc.nodes[p].parent
|
|
80
|
+
doc.nodes[t].parent = a["parent"]
|
|
81
|
+
doc.nodes[t].order = a.get("order", len(doc.children(a["parent"])))
|
|
82
|
+
elif kind == "merge": # composite (D-035)
|
|
83
|
+
loser, winner = t, a["into"]
|
|
84
|
+
doc.nodes[loser].status = "retracted"
|
|
85
|
+
doc.edges[f"e-merge-{loser}"] = Edge(id=f"e-merge-{loser}", type="merged-into",
|
|
86
|
+
source=loser, target=winner)
|
|
87
|
+
for e in doc.edges.values():
|
|
88
|
+
if e.target == loser and e.type != "merged-into":
|
|
89
|
+
e.target = winner
|
|
90
|
+
elif kind == "tag":
|
|
91
|
+
return apply(doc, {**op, "kind": "add-edge",
|
|
92
|
+
"args": {"type": "tag", "source": t, "target": a["concept"]}})
|
|
93
|
+
else:
|
|
94
|
+
raise OpRejected(f"unknown op kind {kind}")
|
|
95
|
+
|
|
96
|
+
obj = doc.nodes.get(t) or doc.edges.get(t) if t else None
|
|
97
|
+
if obj is not None:
|
|
98
|
+
obj.version += 1
|
|
99
|
+
diags = doc.check_invariants()
|
|
100
|
+
if diags:
|
|
101
|
+
raise OpRejected(f"op would break invariants: {diags}") # RM14: closed op set
|
|
102
|
+
return doc
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def fold(doc: Doc, ops: list) -> Doc:
|
|
106
|
+
"""State = fold(op-set), order-independent: sort by Lamport ts (counter, replica) then apply.
|
|
107
|
+
Any permutation of `ops` yields the same state (D-037/D-039)."""
|
|
108
|
+
for op in sorted(ops, key=lambda o: (o.get("ts", [0, 0])[1], o.get("ts", [0, 0])[0], o.get("id", ""))):
|
|
109
|
+
apply(doc, op)
|
|
110
|
+
return doc
|
sarib/parser.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""sarib.parser — Candidate-A surface -> model. Tier-0 TOTAL parse (D-049):
|
|
2
|
+
every byte string yields a model; unrecognized lines become L0 prose nodes.
|
|
3
|
+
Local, line-shaped, no backtracking (D-045). Deterministic ids in document order.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import re
|
|
7
|
+
from .model import Doc, Node, Edge, normalize_name
|
|
8
|
+
|
|
9
|
+
H_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
10
|
+
LI_RE = re.compile(r"^(\s*)[-*]\s+(.*)$")
|
|
11
|
+
FIELD_RE = re.compile(r"^([A-Za-z][\w-]*)::\s*(.*)$")
|
|
12
|
+
ATTR_RE = re.compile(r"\s*\{([^{}]*)\}\s*$")
|
|
13
|
+
BID_RE = re.compile(r"\s*\^([\w-]+)\s*$")
|
|
14
|
+
TYPED_REF_RE = re.compile(r"\[([A-Za-z][\w-]*)::\s*\[\[([^\[\]]+)\]\]\]")
|
|
15
|
+
WIKI_RE = re.compile(r"\[\[([^\[\]]+)\]\]")
|
|
16
|
+
COOKIE_RE = re.compile(r"\s*\[\d+/\d+[^\]]*\]\s*$") # derived cue: ignored on parse (D-051)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _strip_marks(text: str):
|
|
20
|
+
"""Peel trailing ^id, {attrs}, and derived [k/n] cookies off a title line."""
|
|
21
|
+
nid = slug = ntype = None
|
|
22
|
+
props = {}
|
|
23
|
+
changed = True
|
|
24
|
+
while changed: # peel trailing marks in any order until fixed-point
|
|
25
|
+
changed = False
|
|
26
|
+
m = COOKIE_RE.search(text)
|
|
27
|
+
if m:
|
|
28
|
+
text, changed = text[: m.start()], True
|
|
29
|
+
continue
|
|
30
|
+
m = BID_RE.search(text)
|
|
31
|
+
if m:
|
|
32
|
+
nid, text, changed = m.group(1), text[: m.start()], True
|
|
33
|
+
continue
|
|
34
|
+
m = ATTR_RE.search(text)
|
|
35
|
+
if m:
|
|
36
|
+
for tok in m.group(1).split():
|
|
37
|
+
if tok.startswith("."):
|
|
38
|
+
ntype = tok[1:]
|
|
39
|
+
elif tok.startswith("#"):
|
|
40
|
+
slug = tok[1:]
|
|
41
|
+
elif "=" in tok:
|
|
42
|
+
k, v = tok.split("=", 1)
|
|
43
|
+
props[k] = v.strip('"')
|
|
44
|
+
text, changed = text[: m.start()], True
|
|
45
|
+
return text.strip(), nid, slug, ntype, props
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse(src: str) -> Doc:
|
|
49
|
+
doc = Doc()
|
|
50
|
+
counter = [0]
|
|
51
|
+
|
|
52
|
+
def make_id(explicit):
|
|
53
|
+
if explicit:
|
|
54
|
+
return explicit
|
|
55
|
+
counter[0] += 1
|
|
56
|
+
return f"n{counter[0]}"
|
|
57
|
+
|
|
58
|
+
lines = src.splitlines()
|
|
59
|
+
i = 0
|
|
60
|
+
# -- front matter --
|
|
61
|
+
if lines and lines[0].strip() == "---":
|
|
62
|
+
j = 1
|
|
63
|
+
while j < len(lines) and lines[j].strip() != "---":
|
|
64
|
+
if ":" in lines[j]:
|
|
65
|
+
k, v = lines[j].split(":", 1)
|
|
66
|
+
doc.meta[k.strip()] = v.strip()
|
|
67
|
+
j += 1
|
|
68
|
+
i = j + 1
|
|
69
|
+
|
|
70
|
+
stack = [] # [(heading_level, node_id)]
|
|
71
|
+
current = None # node fields attach to (last heading/item)
|
|
72
|
+
para: list = []
|
|
73
|
+
pending_edges = [] # (source_id, rel, target_name)
|
|
74
|
+
|
|
75
|
+
def container():
|
|
76
|
+
return stack[-1][1] if stack else None
|
|
77
|
+
|
|
78
|
+
def scan_inline(source_id: str, text: str):
|
|
79
|
+
for m in TYPED_REF_RE.finditer(text):
|
|
80
|
+
pending_edges.append((source_id, m.group(1), m.group(2)))
|
|
81
|
+
stripped = TYPED_REF_RE.sub("", text)
|
|
82
|
+
for m in WIKI_RE.finditer(stripped):
|
|
83
|
+
pending_edges.append((source_id, "relates-to", m.group(1)))
|
|
84
|
+
|
|
85
|
+
def flush_para():
|
|
86
|
+
nonlocal para
|
|
87
|
+
if not para:
|
|
88
|
+
return
|
|
89
|
+
text = "\n".join(para).strip()
|
|
90
|
+
para = []
|
|
91
|
+
if not text or text.startswith("<!--"):
|
|
92
|
+
return
|
|
93
|
+
n = Node(id=make_id(None), kind_hint="prose", content=text,
|
|
94
|
+
parent=container(), order=len(doc.children(container())))
|
|
95
|
+
doc.nodes[n.id] = n
|
|
96
|
+
scan_inline(n.id, text)
|
|
97
|
+
|
|
98
|
+
while i < len(lines):
|
|
99
|
+
line = lines[i]
|
|
100
|
+
# multi-line HTML comments: skip wholesale
|
|
101
|
+
if line.lstrip().startswith("<!--"):
|
|
102
|
+
while i < len(lines) and "-->" not in lines[i]:
|
|
103
|
+
i += 1
|
|
104
|
+
i += 1
|
|
105
|
+
continue
|
|
106
|
+
if not line.strip():
|
|
107
|
+
flush_para()
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
m = H_RE.match(line)
|
|
112
|
+
if m:
|
|
113
|
+
flush_para()
|
|
114
|
+
level = len(m.group(1))
|
|
115
|
+
text, nid, slug, ntype, props = _strip_marks(m.group(2))
|
|
116
|
+
while stack and stack[-1][0] >= level:
|
|
117
|
+
stack.pop()
|
|
118
|
+
n = Node(id=make_id(nid), kind_hint="heading", title=text, type=ntype,
|
|
119
|
+
slug=slug, properties=props, parent=container(),
|
|
120
|
+
order=len(doc.children(container())))
|
|
121
|
+
n.properties["_level"] = level
|
|
122
|
+
doc.nodes[n.id] = n
|
|
123
|
+
scan_inline(n.id, text)
|
|
124
|
+
stack.append((level, n.id))
|
|
125
|
+
current = n
|
|
126
|
+
i += 1
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
m = LI_RE.match(line)
|
|
130
|
+
if m:
|
|
131
|
+
flush_para()
|
|
132
|
+
text, nid, slug, ntype, props = _strip_marks(m.group(2))
|
|
133
|
+
n = Node(id=make_id(nid), kind_hint="item", title=text, type=ntype,
|
|
134
|
+
slug=slug, properties=props, parent=container(),
|
|
135
|
+
order=len(doc.children(container())))
|
|
136
|
+
doc.nodes[n.id] = n
|
|
137
|
+
scan_inline(n.id, text)
|
|
138
|
+
current = n
|
|
139
|
+
i += 1
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
m = FIELD_RE.match(line)
|
|
143
|
+
if m and current is not None:
|
|
144
|
+
key, val = m.group(1), m.group(2).strip()
|
|
145
|
+
wl = WIKI_RE.fullmatch(val)
|
|
146
|
+
if wl: # field whose value is a wikilink = a typed edge (owner:: [[Alice]])
|
|
147
|
+
pending_edges.append((current.id, key, wl.group(1)))
|
|
148
|
+
current.properties[key] = val # keep raw for surface round-trip (D-051);
|
|
149
|
+
# canon skips wikilink-valued props (edge-represented)
|
|
150
|
+
else:
|
|
151
|
+
current.properties[key] = val
|
|
152
|
+
i += 1
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
para.append(line)
|
|
156
|
+
i += 1
|
|
157
|
+
flush_para()
|
|
158
|
+
|
|
159
|
+
# -- reference resolution (D-024: explicit slug -> nearest-in-containment -> global -> unresolved) --
|
|
160
|
+
by_name: dict = {}
|
|
161
|
+
for n in doc.nodes.values():
|
|
162
|
+
if n.title:
|
|
163
|
+
by_name.setdefault(normalize_name(n.title), []).append(n.id)
|
|
164
|
+
|
|
165
|
+
def resolve(source_id: str, name: str):
|
|
166
|
+
if name.startswith("#"):
|
|
167
|
+
hit = doc.node_by_slug(name[1:])
|
|
168
|
+
return hit.id if hit else None
|
|
169
|
+
cands = by_name.get(normalize_name(name), [])
|
|
170
|
+
if len(cands) == 1:
|
|
171
|
+
return cands[0]
|
|
172
|
+
if len(cands) > 1: # nearest-in-containment: walk up source ancestors
|
|
173
|
+
anc, p = [], doc.nodes.get(source_id)
|
|
174
|
+
while p is not None:
|
|
175
|
+
anc.append(p.id)
|
|
176
|
+
p = doc.nodes.get(p.parent) if p.parent else None
|
|
177
|
+
def dist(cid):
|
|
178
|
+
q, d = doc.nodes.get(cid), 0
|
|
179
|
+
while q is not None:
|
|
180
|
+
if q.id in anc:
|
|
181
|
+
return d + anc.index(q.id)
|
|
182
|
+
q = doc.nodes.get(q.parent) if q.parent else None
|
|
183
|
+
d += 1
|
|
184
|
+
return 10 ** 6
|
|
185
|
+
ranked = sorted(cands, key=lambda c: (dist(c), c))
|
|
186
|
+
if dist(ranked[0]) < dist(ranked[1]):
|
|
187
|
+
return ranked[0]
|
|
188
|
+
doc.diagnostics.append(f"ambiguous-reference: '{name}' from {source_id}")
|
|
189
|
+
return None
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
ec = 0
|
|
193
|
+
for src_id, rel, name in pending_edges:
|
|
194
|
+
tid = resolve(src_id, name)
|
|
195
|
+
ec += 1
|
|
196
|
+
if tid is None:
|
|
197
|
+
doc.diagnostics.append(f"unresolved-reference: '{name}' from {src_id}")
|
|
198
|
+
tid = f"?unresolved:{name}"
|
|
199
|
+
doc.edges[f"e{ec}"] = Edge(id=f"e{ec}", type=rel, source=src_id, target=tid)
|
|
200
|
+
|
|
201
|
+
doc.diagnostics.extend(doc.check_invariants())
|
|
202
|
+
return doc
|
sarib/query.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""sarib.query — the 7-axis parameterized walk (Stage 6/7, D-026..D-034).
|
|
2
|
+
spec = {start, select, direction, order, filter, bound, derivation, projection}
|
|
3
|
+
Returns a bounded result subgraph carrying stable ids (D-032). Cycle-safe (D-027).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from .model import Doc, anchor_owner
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _match(doc: Doc, n, flt) -> bool:
|
|
10
|
+
if not flt:
|
|
11
|
+
return True
|
|
12
|
+
if "type" in flt and (n.type or "") != flt["type"] and not (n.type or "").endswith(":" + flt["type"]):
|
|
13
|
+
return False
|
|
14
|
+
if "status" in flt and n.properties.get("status", n.status) != flt["status"]:
|
|
15
|
+
return False
|
|
16
|
+
for key, op, val in flt.get("prop", []):
|
|
17
|
+
have = n.properties.get(key)
|
|
18
|
+
if op == "=" and str(have) != str(val):
|
|
19
|
+
return False
|
|
20
|
+
if op == "!=" and str(have) == str(val):
|
|
21
|
+
return False
|
|
22
|
+
if op == "exists" and have is None:
|
|
23
|
+
return False
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def query(doc: Doc, spec: dict) -> dict:
|
|
28
|
+
start = spec.get("start", "all")
|
|
29
|
+
select = spec.get("select", "none")
|
|
30
|
+
direction = spec.get("direction", "forward")
|
|
31
|
+
order = spec.get("order", "document")
|
|
32
|
+
flt = spec.get("filter", {})
|
|
33
|
+
bound = spec.get("bound", {"max_nodes": 500})
|
|
34
|
+
proj = spec.get("projection", ["id", "type", "title", "props"])
|
|
35
|
+
maxn = bound.get("max_nodes", 500)
|
|
36
|
+
maxd = bound.get("max_depth", 50)
|
|
37
|
+
|
|
38
|
+
result_ids, result_edges, cursor = [], [], None
|
|
39
|
+
|
|
40
|
+
if select == "none": # pure selection (priority/chronological case)
|
|
41
|
+
pool = [n for n in doc.walk(None) if _match(doc, n, flt)]
|
|
42
|
+
if order.startswith("by-"):
|
|
43
|
+
key = order[3:]
|
|
44
|
+
pool.sort(key=lambda n: (str(n.properties.get(key, "~")), n.id))
|
|
45
|
+
result_ids = [n.id for n in pool[:maxn]]
|
|
46
|
+
cursor = pool[maxn].id if len(pool) > maxn else None
|
|
47
|
+
else: # graph walk
|
|
48
|
+
seeds = [s for s in ([start] if isinstance(start, str) else start) if s in doc.nodes]
|
|
49
|
+
seen, frontier, depth = set(), list(seeds), 0
|
|
50
|
+
while frontier and len(result_ids) < maxn and depth <= maxd:
|
|
51
|
+
nxt = []
|
|
52
|
+
for nid in frontier:
|
|
53
|
+
if nid in seen: # cycle-safe: emit-once (D-027)
|
|
54
|
+
continue
|
|
55
|
+
seen.add(nid)
|
|
56
|
+
n = doc.nodes[nid]
|
|
57
|
+
if _match(doc, n, flt):
|
|
58
|
+
result_ids.append(nid)
|
|
59
|
+
if select in ("contains", "any"):
|
|
60
|
+
for c in doc.children(nid):
|
|
61
|
+
nxt.append(c.id)
|
|
62
|
+
if select != "contains":
|
|
63
|
+
for eid in sorted(doc.edges): # tie-break cascade (D-029)
|
|
64
|
+
e = doc.edges[eid]
|
|
65
|
+
if e.status != "active" or e.target.startswith("?unresolved:"):
|
|
66
|
+
continue
|
|
67
|
+
etypes = select if isinstance(select, list) else [select]
|
|
68
|
+
if select != "any" and e.type not in etypes:
|
|
69
|
+
continue
|
|
70
|
+
esrc = anchor_owner(doc, e.source)
|
|
71
|
+
etgt = anchor_owner(doc, e.target)
|
|
72
|
+
if direction in ("forward", "both") and esrc == nid:
|
|
73
|
+
nxt.append(etgt); result_edges.append(eid)
|
|
74
|
+
if direction in ("backward", "both") and etgt == nid:
|
|
75
|
+
nxt.append(esrc); result_edges.append(eid)
|
|
76
|
+
frontier = nxt
|
|
77
|
+
depth += 1
|
|
78
|
+
if frontier:
|
|
79
|
+
cursor = frontier[0]
|
|
80
|
+
|
|
81
|
+
def project(nid):
|
|
82
|
+
n = doc.nodes[nid]
|
|
83
|
+
rec = {}
|
|
84
|
+
for f in proj:
|
|
85
|
+
if f == "props":
|
|
86
|
+
rec["props"] = {k: v for k, v in n.properties.items() if not k.startswith("_")}
|
|
87
|
+
elif f == "title":
|
|
88
|
+
rec["title"] = n.name()
|
|
89
|
+
else:
|
|
90
|
+
rec[f] = getattr(n, f, None)
|
|
91
|
+
return rec
|
|
92
|
+
|
|
93
|
+
return {"nodes": [project(i) for i in result_ids],
|
|
94
|
+
"edges": [{"id": e, "type": doc.edges[e].type,
|
|
95
|
+
"source": anchor_owner(doc, doc.edges[e].source),
|
|
96
|
+
"target": anchor_owner(doc, doc.edges[e].target)}
|
|
97
|
+
for e in dict.fromkeys(result_edges)],
|
|
98
|
+
"cursor": cursor,
|
|
99
|
+
"diagnostics": doc.diagnostics}
|
sarib/render.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""sarib.render — projections (Stage 12, D-052): view = query + template.
|
|
2
|
+
document (= fmt, the live canonical surface), outline (+spatial cues, D-047),
|
|
3
|
+
board (terminal), mermaid (terminal, D-053).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from .model import Doc, anchor_owner
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fmt(doc: Doc) -> str:
|
|
10
|
+
"""Model -> Candidate-A surface. The document projection; idempotent (D-051)."""
|
|
11
|
+
out = []
|
|
12
|
+
if doc.meta:
|
|
13
|
+
out.append("---")
|
|
14
|
+
for k, v in doc.meta.items():
|
|
15
|
+
out.append(f"{k}: {v}")
|
|
16
|
+
out.append("---\n")
|
|
17
|
+
for n in doc.walk(None):
|
|
18
|
+
if n.kind_hint == "heading":
|
|
19
|
+
level = int(n.properties.get("_level", _depth(doc, n) + 1))
|
|
20
|
+
marks = ""
|
|
21
|
+
attrs = []
|
|
22
|
+
if n.type:
|
|
23
|
+
attrs.append(f".{n.type}")
|
|
24
|
+
if n.slug:
|
|
25
|
+
attrs.append(f"#{n.slug}")
|
|
26
|
+
if attrs:
|
|
27
|
+
marks += " {" + " ".join(attrs) + "}"
|
|
28
|
+
if not n.id.startswith("n") or not n.id[1:].isdigit():
|
|
29
|
+
marks += f" ^{n.id}"
|
|
30
|
+
out.append(f"{'#' * level} {n.title}{marks}")
|
|
31
|
+
for k, v in n.properties.items():
|
|
32
|
+
if not k.startswith("_"):
|
|
33
|
+
out.append(f"{k}:: {v}")
|
|
34
|
+
out.append("")
|
|
35
|
+
elif n.kind_hint == "item":
|
|
36
|
+
t = f"- {n.title}"
|
|
37
|
+
if n.type:
|
|
38
|
+
t += f" {{.{n.type}}}"
|
|
39
|
+
out.append(t)
|
|
40
|
+
else:
|
|
41
|
+
out.append(n.content + "\n")
|
|
42
|
+
return "\n".join(out).rstrip() + "\n"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _depth(doc: Doc, n) -> int:
|
|
46
|
+
d, p = 0, n.parent
|
|
47
|
+
while p is not None:
|
|
48
|
+
d, p = d + 1, doc.nodes[p].parent
|
|
49
|
+
return d
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def outline(doc: Doc) -> str:
|
|
53
|
+
"""Outline view with spatial cues: depth indent, [k/n] task cookies, subtree size (D-047)."""
|
|
54
|
+
out = []
|
|
55
|
+
for n in doc.walk(None):
|
|
56
|
+
if n.kind_hint == "prose":
|
|
57
|
+
continue
|
|
58
|
+
d = _depth(doc, n)
|
|
59
|
+
sub = list(doc.walk(n.id))
|
|
60
|
+
tasks = [m for m in sub if m.type and m.type.endswith("task")]
|
|
61
|
+
cue = ""
|
|
62
|
+
if tasks:
|
|
63
|
+
done = sum(1 for t in tasks if t.properties.get("status") == "done")
|
|
64
|
+
cue = f" [{done}/{len(tasks)}]"
|
|
65
|
+
size = f" ({len(sub)})" if sub else ""
|
|
66
|
+
typ = f" ·{n.type}" if n.type else ""
|
|
67
|
+
out.append(f"{' ' * d}- {n.title}{typ}{cue}{size}")
|
|
68
|
+
return "\n".join(out) + "\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def board(doc: Doc) -> str:
|
|
72
|
+
"""Kanban projection of tasks by status (terminal export)."""
|
|
73
|
+
cols: dict = {}
|
|
74
|
+
for n in doc.walk(None):
|
|
75
|
+
if n.type and n.type.endswith("task"):
|
|
76
|
+
cols.setdefault(n.properties.get("status", "todo"), []).append(n)
|
|
77
|
+
out = []
|
|
78
|
+
for status in sorted(cols):
|
|
79
|
+
out.append(f"== {status.upper()} ({len(cols[status])}) ==")
|
|
80
|
+
for n in cols[status]:
|
|
81
|
+
due = f" (due {n.properties['due']})" if "due" in n.properties else ""
|
|
82
|
+
out.append(f" • {n.title}{due} [{n.id}]")
|
|
83
|
+
return "\n".join(out) + "\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def mermaid(doc: Doc) -> str:
|
|
87
|
+
"""Dependency-graph projection -> Mermaid (terminal export, D-053: self-declared read-only)."""
|
|
88
|
+
out = ["%% terminal export — edits do not flow back (D-053)", "flowchart TD"]
|
|
89
|
+
for e in doc.edges.values():
|
|
90
|
+
if e.status != "active" or e.target.startswith("?unresolved:"):
|
|
91
|
+
continue
|
|
92
|
+
s = doc.nodes.get(anchor_owner(doc, e.source))
|
|
93
|
+
t = doc.nodes.get(anchor_owner(doc, e.target))
|
|
94
|
+
if s and t and s.id != t.id:
|
|
95
|
+
out.append(f' {s.id}["{s.name()}"] -->|{e.type}| {t.id}["{t.name()}"]')
|
|
96
|
+
return "\n".join(out) + "\n"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
VIEWS = {"document": fmt, "outline": outline, "board": board, "mermaid": mermaid}
|
|
@@ -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.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
sarib/__init__.py,sha256=irzBkLDEZmAmO6RpDzziyeP489DTSIHxLjT6AJJgXTM,339
|
|
2
|
+
sarib/canon.py,sha256=WDKu6d4mmhffdUhtdiKZCv6gRnImul5NM4udvoR0UEA,2000
|
|
3
|
+
sarib/cli.py,sha256=qIc3tS9w44269R9xrwZCdgWRblOzC_EwEiKWZ_IB1UM,3717
|
|
4
|
+
sarib/importer.py,sha256=aM2SJx0R9ogNdJV7zfdxQvqToUQ4nH8RgaQw323h9Us,14156
|
|
5
|
+
sarib/mcp_server.py,sha256=t7fWdTb5IHE-rMW_kPWYFJ2hjTOXYE7uXZr2kLZJUyE,2933
|
|
6
|
+
sarib/model.py,sha256=2Rfe9NqpuM4EGzBohYOlVD35VPdQjPuKnZZMxK9QGcI,4297
|
|
7
|
+
sarib/ops.py,sha256=J4aoHv1zz7l6Q7N5LVVEe9xCQs3SebFJLiNYXCvK8R8,5080
|
|
8
|
+
sarib/parser.py,sha256=7gOrB6pWTjsOT5ITlLjp_h65xIbD0XVqIqM8FptplVc,7345
|
|
9
|
+
sarib/query.py,sha256=Ogjt6_LyV_9NgRom3FcpKEfTVs_UjciVlmmjuV4ynrQ,4331
|
|
10
|
+
sarib/render.py,sha256=smOo4MFN8DBZJQTdt9z06oafzTCpxi0s3rS9-r6OcjY,3639
|
|
11
|
+
sarib-0.1.0.dist-info/METADATA,sha256=nOXweifPyyvhVbADo_IfSOyhbqIBkHVpgXazB5jCWzw,2108
|
|
12
|
+
sarib-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
sarib-0.1.0.dist-info/entry_points.txt,sha256=_FOva6SOVbeLRd3zLr34XlsXyEKQaj5ToojxvWsUKRc,75
|
|
14
|
+
sarib-0.1.0.dist-info/top_level.txt,sha256=CMi2JK_rrBycJIg-N6SrBPfiX8_9nhOXCftH6DgG2EU,6
|
|
15
|
+
sarib-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sarib
|