zettelkasten.synapse.memdsl.parser¶
zettelkasten.synapse.memdsl.parser ¶
Deterministic, injection-safe (de)serializers for the MemDSL contract.
This module is the wire half of the contract: it turns the in-memory types from
:mod:zettelkasten.synapse.memdsl.schema into a compact, line-oriented text
form and back, in all three directions:
- RENDER (engine → agent): :func:
serialize_envelope/ :func:parse_envelope. - OUTPUT (agent → engine): :func:
serialize_output/ :func:parse_output. - ACTION-RESPONSE (engine → agent, mid-loop): :func:
serialize_response/ :func:parse_response.
Two properties are load-bearing:
- Deterministic — every serializer is a pure function of its input, emitting
fields in a fixed order, so identical input yields byte-identical output and
parse(serialize(x)) == x. - Injection-safe — untrusted node bodies are framed line-by-line with a
|continuation prefix, so a body line that looks like a control token (@answer,ground(n1),node …) can NEVER be read as structure. The three directions also carry distinct headers, so a render envelope fed to :func:parse_outputis rejected outright rather than mined for actions. This mirrors the untrusted-content framing used in :mod:zettelkasten.synapse.synthesis, moved from a prompt convention into the grammar itself.
The grammar is a small hand-written line format rather than a parser-generator dependency: robust, fully tested, and easy to audit for the injection property.
serialize_envelope ¶
serialize_envelope(env: Envelope) -> str
Serialize a render :class:Envelope to MemDSL text (deterministic).
Source code in zettelkasten/synapse/memdsl/parser.py
parse_envelope ¶
parse_envelope(text: str) -> Envelope
Parse MemDSL render text back into an :class:Envelope.
Raises :class:MemDSLParseError on a bad/foreign header or malformed block.
Source code in zettelkasten/synapse/memdsl/parser.py
serialize_response ¶
serialize_response(resp: ActionResponse) -> str
Serialize an :class:ActionResponse fragment to MemDSL text.
v1 emits status + read results (nodes/gaps); the reserved mut/
impact lines only appear when future write actions populate them.
Source code in zettelkasten/synapse/memdsl/parser.py
parse_response ¶
parse_response(text: str) -> ActionResponse
Parse an :class:ActionResponse fragment back from MemDSL text.
Source code in zettelkasten/synapse/memdsl/parser.py
serialize_output ¶
serialize_output(out: AgentOutput) -> str
Serialize an :class:AgentOutput (actions + terminal decision) to MemDSL text.
Raises :class:MemDSLParseError if both a reasoning answer and an
abstain are present (they are mutually exclusive terminal decisions).
The conclude line is emitted whenever the reasoning-DAG has claims: the
conclusion is canonicalized to the last claim id at DAG construction (see
:meth:ReasoningDAG.__post_init__), so there is no empty-conclusion case for
the serializer to drop. This is what makes parse(serialize(x)) == x hold,
closing the old asymmetry where the serializer omitted conclude but the
parser back-filled it.
Source code in zettelkasten/synapse/memdsl/parser.py
1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 | |
parse_output ¶
parse_output(text: str) -> AgentOutput
Parse agent OUTPUT text into an :class:AgentOutput.
The required @memdsl.out/<ver> header is what makes this injection-safe:
a render envelope or a raw untrusted node body has a different (or no) header,
so it is rejected here rather than mined for actions. Within an @answer
block, v1 carries claims/conclude and v2 additionally requires
prose plus exact cite bindings.
A PURELY SYNTACTIC, MARKER-DELIMITED pre-parse recovery
(:func:_recover_output_envelope) first unwraps a single, exactly-matched
enclosing markdown code fence (with only blank lines outside it), so a
production LLM's common near-miss still yields an answer. It trims NOTHING it
would have to classify as prose-vs-structural — only a matched fence pair's
wrapper and leading/trailing blank lines — and hands the envelope interior
VERBATIM to the strict parse below, which requires the first non-blank line to
be the header and consumes to the end or refuses. So recovery can launder
nothing: a pristine structural line OUTSIDE the recovered core (pre-header
preamble, or outside a fence) is never dropped — it stays in the interior and
is rejected here, or lies outside the fence and refuses — and everything below
fails-closed.
Exactly one terminal decision is allowed: @answer XOR @abstain, once.
A second terminal, or any action AFTER a terminal, is a
:class:MemDSLParseError — a turn ends at its single terminal decision, so a
trailing action can never silently ride along after it.
Source code in zettelkasten/synapse/memdsl/parser.py
1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 | |
assign_refs ¶
assign_refs(nodes: list[RenderedNode], *, prefix: str = 'n', start: int = 1) -> list[RenderedNode]
Assign stable, deterministic envelope-local refs (n1, n2, …) in order.
Mutates each node's ref in list order and returns the list, so the same
node sequence always yields the same refs — the "stable id assignment" half
of deterministic rendering. Ordering of the nodes themselves is the caller's
responsibility (the serializer preserves it).