#!/usr/bin/env python3 """mock_olrapi_hifi.py — H4: higher-fidelity mock of the Obsidian Local REST API. The shipped mock's PATCH is a "naive append": it appends the body to EOF instead of inserting/replacing under the target heading, so the 0.9 surface (capture's _append_to_existing, scope set's replace, the daily Agent-Log insert) is only ever validated against behavior the real API does NOT exhibit. This subclass models the real PATCH semantics so the eval can catch heading-insert / replace regressions. Over the base mock (eval/mock_olrapi.py) this overrides do_PATCH only: * heading append -> insert at the END of the target section's body (before the next sibling/parent heading), NOT at EOF. * heading prepend -> insert at the START of the section body (right after the heading). * heading replace -> replace the section body, keep the heading line. * heading missing -> 400 errorCode 40080 (the silent-write-loss trigger). [unchanged] * frontmatter replace -> parse the YAML block and set/replace the scalar; a replace on a MISSING field returns 400 40080 (matches the real API, per SKILL.md). Everything else (GET/PUT/POST/DELETE, directory listing, document-map, flaky/phantom fault injection, debug reset) is inherited unchanged from the base handler. """ from __future__ import annotations import argparse import json import re from http.server import ThreadingHTTPServer import mock_olrapi from mock_olrapi import H as BaseH, STATE def _heading(line: str): m = re.match(r"^(#{1,6})\s+(.*)$", line) return (len(m.group(1)), m.group(2).strip()) if m else (0, None) def section_span(lines, target): """(start, end, level) for the section under `target` (an "A::B" path; matched on its leaf). Body lines are lines[start+1:end]. None if the heading is absent.""" leaf = target.split("::")[-1].strip() start = level = None for i, line in enumerate(lines): lv, txt = _heading(line) if lv and txt == leaf: start, level = i, lv break if start is None: return None end = len(lines) for j in range(start + 1, len(lines)): lv, _ = _heading(lines[j]) if lv and lv <= level: end = j break return start, end, level def set_frontmatter(text: str, field: str, raw_value: str): """Return (new_text, existed). raw_value is the JSON the client PATCHed.""" try: value = json.loads(raw_value) except Exception: value = raw_value.strip().strip('"') rendered = value if isinstance(value, str) else json.dumps(value) new_line = f"{field}: {rendered}" if not text.startswith("---"): return f"---\n{new_line}\n---\n\n{text}", False end = text.find("\n---", 3) head = text[4:end] if end != -1 else text rest = text[end:] if end != -1 else "\n---\n" out, existed = [], False for line in head.splitlines(): if re.match(rf"^{re.escape(field)}\s*:", line): out.append(new_line) existed = True else: out.append(line) if not existed: out.append(new_line) return "---\n" + "\n".join(out) + rest, existed class H(BaseH): def do_PATCH(self): vp = self._vpath() body = self._read_body() if vp is None: return self._json(400, {"message": "bad path"}) ttype = self.headers.get("Target-Type", "") op = self.headers.get("Operation", "append") target = self.headers.get("Target", "") cur = STATE.get(vp, "") if ttype == "heading": lines = cur.split("\n") span = section_span(lines, target) if span is None: return self._json(400, {"errorCode": 40080, "message": "invalid-target"}) start, end, _ = span payload = body.strip("\n").split("\n") if body.strip("\n") else [""] if op == "replace": lines[start + 1:end] = payload elif op == "prepend": lines[start + 1:start + 1] = payload else: # append at the tail of the section body (before the next heading) ins = end while ins > start + 1 and lines[ins - 1].strip() == "": ins -= 1 lines[ins:ins] = payload STATE[vp] = "\n".join(lines) return self._send(200, "") if ttype == "frontmatter": new, existed = set_frontmatter(cur, target, body) if op == "replace" and not existed: return self._json(400, {"errorCode": 40080, "message": "invalid-target"}) STATE[vp] = new return self._send(200, "") STATE[vp] = cur + "\n" + body return self._send(200, "") def main(): ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=8800) a = ap.parse_args() srv = ThreadingHTTPServer(("127.0.0.1", a.port), H) print(f"mock-olrapi-hifi listening on http://127.0.0.1:{a.port}", flush=True) srv.serve_forever() if __name__ == "__main__": main()