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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sarib = sarib.cli:main
3
+ sarib-mcp = sarib.mcp_server:main
@@ -0,0 +1 @@
1
+ sarib