memdsl 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.
memdsl/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """memdsl: agent memory as normative source code.
2
+
3
+ A lintable, queryable memory DSL for LLM agents. Memory is written as
4
+ `.mem` source files with named, typed, scoped declarations backed by
5
+ evidence -- then linted like code and queried into layered evidence
6
+ packs (MUST / SHOULD / CONTEXT / CONFLICT / MISSING) that an LLM can
7
+ follow.
8
+ """
9
+
10
+ from memdsl.parser import parse_file, parse_text, ParseError
11
+ from memdsl.model import Workspace, Declaration
12
+ from memdsl.linter import lint
13
+ from memdsl.query import build_evidence_pack, EvidencePack
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "parse_file",
19
+ "parse_text",
20
+ "ParseError",
21
+ "Workspace",
22
+ "Declaration",
23
+ "lint",
24
+ "build_evidence_pack",
25
+ "EvidencePack",
26
+ "__version__",
27
+ ]
memdsl/cli.py ADDED
@@ -0,0 +1,89 @@
1
+ """memdsl command-line interface.
2
+
3
+ memdsl lint PATH... lint memory source files
4
+ memdsl query PATH... -q TEXT build an evidence pack for a query
5
+ memdsl explain PATH... ID show one declaration with relations
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from typing import List
13
+
14
+ from memdsl import __version__
15
+ from memdsl.linter import has_errors, lint
16
+ from memdsl.model import Workspace
17
+ from memdsl.parser import ParseError
18
+ from memdsl.query import build_evidence_pack, explain
19
+
20
+
21
+ def _load(paths: List[str]) -> Workspace:
22
+ try:
23
+ return Workspace.load(paths)
24
+ except ParseError as e:
25
+ print(f"parse error: {e}", file=sys.stderr)
26
+ sys.exit(2)
27
+ except OSError as e:
28
+ print(f"cannot read input: {e}", file=sys.stderr)
29
+ sys.exit(2)
30
+
31
+
32
+ def main(argv: List[str] = None) -> int:
33
+ parser = argparse.ArgumentParser(
34
+ prog="memdsl",
35
+ description="Agent memory as normative source code.")
36
+ parser.add_argument("--version", action="version",
37
+ version=f"memdsl {__version__}")
38
+ sub = parser.add_subparsers(dest="command", required=True)
39
+
40
+ p_lint = sub.add_parser("lint", help="lint .mem files")
41
+ p_lint.add_argument("paths", nargs="+", help=".mem files or directories")
42
+ p_lint.add_argument("--strict", action="store_true",
43
+ help="exit non-zero on warnings too")
44
+
45
+ p_query = sub.add_parser("query", help="query memory into an evidence pack")
46
+ p_query.add_argument("paths", nargs="+", help=".mem files or directories")
47
+ p_query.add_argument("-q", "--query", required=True, help="query text")
48
+ p_query.add_argument("--kind", action="append", dest="kinds",
49
+ help="restrict to declaration kind (repeatable)")
50
+ p_query.add_argument("--subject", help="restrict to a subject symbol")
51
+ p_query.add_argument("--limit", type=int, default=8)
52
+ p_query.add_argument("--json", action="store_true", help="JSON output")
53
+
54
+ p_explain = sub.add_parser("explain", help="show one declaration")
55
+ p_explain.add_argument("paths", nargs="+", help=".mem files or directories")
56
+ p_explain.add_argument("id", help="declaration id (kind:name or name)")
57
+
58
+ args = parser.parse_args(argv)
59
+
60
+ if args.command == "lint":
61
+ ws = _load(args.paths)
62
+ diags = lint(ws)
63
+ for d in diags:
64
+ print(d.render())
65
+ errors = sum(1 for d in diags if d.severity == "error")
66
+ warnings = sum(1 for d in diags if d.severity == "warning")
67
+ print(f"\n{len(ws.declarations)} declarations, "
68
+ f"{errors} error(s), {warnings} warning(s)")
69
+ if has_errors(diags) or (args.strict and warnings):
70
+ return 1
71
+ return 0
72
+
73
+ if args.command == "query":
74
+ ws = _load(args.paths)
75
+ pack = build_evidence_pack(ws, args.query, kinds=args.kinds,
76
+ subject=args.subject, limit=args.limit)
77
+ print(pack.render_json() if args.json else pack.render_text())
78
+ return 0
79
+
80
+ if args.command == "explain":
81
+ ws = _load(args.paths)
82
+ print(explain(ws, args.id))
83
+ return 0
84
+
85
+ return 2
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
memdsl/linter.py ADDED
@@ -0,0 +1,202 @@
1
+ """Linter: code-style diagnostics for memory source.
2
+
3
+ Implements the v0.1 rule set from the spec:
4
+
5
+ unresolved_symbol error subject/relation target not defined
6
+ ambiguous_alias warning alias resolves to multiple entities
7
+ duplicate_declaration_id error same id declared twice
8
+ duplicate_declaration warning same kind+subject+scope+claim
9
+ missing_evidence error active long-term declaration without evidence
10
+ boundary_without_exception warning hard boundary with no exceptions list
11
+ type_force_mismatch warning preference:hard or boundary:advisory
12
+ stale_state warning state past valid_until / as_of too old
13
+ unmarked_supersede_status warning superseded target not marked superseded
14
+ module_too_large warning module exceeds declaration budget
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime as _dt
20
+ import re
21
+ from dataclasses import dataclass
22
+ from typing import List, Optional
23
+
24
+ from memdsl.model import Workspace, Declaration
25
+
26
+ STALE_STATE_DAYS = 180
27
+ MODULE_MAX_DECLARATIONS = 50
28
+
29
+ #: Kinds that require evidence when active (entities and open issues are exempt).
30
+ EVIDENCE_REQUIRED_KINDS = {
31
+ "fact", "preference", "boundary", "principle", "decision", "state",
32
+ }
33
+
34
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
35
+
36
+
37
+ @dataclass
38
+ class Diagnostic:
39
+ code: str
40
+ severity: str # 'error' | 'warning'
41
+ message: str
42
+ file: str
43
+ line: int
44
+ decl_id: Optional[str] = None
45
+
46
+ def render(self) -> str:
47
+ return f"{self.file}:{self.line}: {self.severity}[{self.code}] {self.message}"
48
+
49
+
50
+ def _parse_date(value) -> Optional[_dt.date]:
51
+ if isinstance(value, str) and _DATE_RE.match(value.strip()):
52
+ try:
53
+ return _dt.date.fromisoformat(value.strip())
54
+ except ValueError:
55
+ return None
56
+ return None
57
+
58
+
59
+ def lint(ws: Workspace, today: Optional[_dt.date] = None) -> List[Diagnostic]:
60
+ today = today or _dt.date.today()
61
+ diags: List[Diagnostic] = []
62
+ known = ws.known_names() | ws.known_symbols()
63
+ superseded_targets = ws.superseded_ids()
64
+
65
+ # duplicate ids
66
+ seen_ids = {}
67
+ for d in ws.declarations:
68
+ if d.id in seen_ids:
69
+ first = seen_ids[d.id]
70
+ diags.append(Diagnostic(
71
+ "duplicate_declaration_id", "error",
72
+ f"'{d.id}' already declared at {first.file}:{first.line}",
73
+ d.file, d.line, d.id))
74
+ else:
75
+ seen_ids[d.id] = d
76
+
77
+ # duplicate content (same kind+subject+scope+claim)
78
+ seen_content = {}
79
+ for d in ws.declarations:
80
+ claim = d.claim_text.strip().lower()
81
+ if not claim:
82
+ continue
83
+ key = (d.kind, d.subject, d.scope, claim)
84
+ if key in seen_content and seen_content[key].id != d.id:
85
+ diags.append(Diagnostic(
86
+ "duplicate_declaration", "warning",
87
+ f"'{d.id}' duplicates '{seen_content[key].id}' "
88
+ f"(same kind/subject/scope/claim)",
89
+ d.file, d.line, d.id))
90
+ else:
91
+ seen_content.setdefault(key, d)
92
+
93
+ # ambiguous aliases
94
+ for alias, symbols in ws.alias_map().items():
95
+ if len(set(symbols)) > 1:
96
+ owners = ", ".join(sorted(set(symbols)))
97
+ entity = ws.by_id(sorted(set(symbols))[0])
98
+ diags.append(Diagnostic(
99
+ "ambiguous_alias", "warning",
100
+ f"alias '{alias}' resolves to multiple entities: {owners}",
101
+ entity.file if entity else "<workspace>",
102
+ entity.line if entity else 0))
103
+
104
+ for d in ws.declarations:
105
+ # unresolved subject symbol
106
+ subject = d.subject
107
+ if subject and subject not in known:
108
+ diags.append(Diagnostic(
109
+ "unresolved_symbol", "error",
110
+ f"subject '{subject}' is not a declared entity or known symbol",
111
+ d.file, d.line, d.id))
112
+
113
+ # unresolved relation targets
114
+ for rel, targets in d.relations().items():
115
+ for target in targets:
116
+ bare = target.split(":", 1)[-1]
117
+ if target not in known and bare not in known:
118
+ diags.append(Diagnostic(
119
+ "unresolved_symbol", "error",
120
+ f"relation '{rel}' points to unknown declaration '{target}'",
121
+ d.file, d.line, d.id))
122
+
123
+ # missing evidence
124
+ if (d.kind in EVIDENCE_REQUIRED_KINDS
125
+ and d.status == "active"
126
+ and d.evidence is None):
127
+ diags.append(Diagnostic(
128
+ "missing_evidence", "error",
129
+ f"active {d.kind} '{d.name}' has no evidence block "
130
+ f"(use status: candidate for unconfirmed memories)",
131
+ d.file, d.line, d.id))
132
+
133
+ # boundary without exception
134
+ if d.kind == "boundary" and "exceptions" not in d.fields:
135
+ diags.append(Diagnostic(
136
+ "boundary_without_exception", "warning",
137
+ f"boundary '{d.name}' declares no exceptions; confirm it is "
138
+ f"truly unconditional or add e.g. [user_explicit_override]",
139
+ d.file, d.line, d.id))
140
+
141
+ # type/force mismatch
142
+ if d.kind == "preference" and d.force == "hard":
143
+ diags.append(Diagnostic(
144
+ "type_force_mismatch", "warning",
145
+ f"preference '{d.name}' uses force: hard; promote it to a "
146
+ f"boundary or lower its force",
147
+ d.file, d.line, d.id))
148
+ if d.kind == "boundary" and d.force == "advisory":
149
+ diags.append(Diagnostic(
150
+ "type_force_mismatch", "warning",
151
+ f"boundary '{d.name}' uses force: advisory; demote it to a "
152
+ f"preference or raise its force",
153
+ d.file, d.line, d.id))
154
+
155
+ # stale state
156
+ if d.kind == "state" and d.status == "active":
157
+ valid_until = _parse_date(d.fields.get("valid_until"))
158
+ as_of = _parse_date(d.fields.get("as_of"))
159
+ if valid_until is not None and valid_until < today:
160
+ diags.append(Diagnostic(
161
+ "stale_state", "warning",
162
+ f"state '{d.name}' expired on {valid_until.isoformat()}",
163
+ d.file, d.line, d.id))
164
+ elif as_of is not None and (today - as_of).days > STALE_STATE_DAYS:
165
+ diags.append(Diagnostic(
166
+ "stale_state", "warning",
167
+ f"state '{d.name}' as_of {as_of.isoformat()} is older than "
168
+ f"{STALE_STATE_DAYS} days; re-confirm or supersede it",
169
+ d.file, d.line, d.id))
170
+ if as_of is None and "as_of" not in d.fields:
171
+ diags.append(Diagnostic(
172
+ "stale_state", "warning",
173
+ f"state '{d.name}' has no as_of date; states must be datable",
174
+ d.file, d.line, d.id))
175
+
176
+ # superseded targets whose status is not updated
177
+ for target in superseded_targets:
178
+ td = ws.by_id(target)
179
+ if td is not None and td.status not in ("superseded", "retracted", "archived"):
180
+ diags.append(Diagnostic(
181
+ "unmarked_supersede_status", "warning",
182
+ f"'{td.id}' is superseded by a newer declaration but its "
183
+ f"status is '{td.status}'; mark it status: superseded",
184
+ td.file, td.line, td.id))
185
+
186
+ # module size budget
187
+ per_module = {}
188
+ for d in ws.declarations:
189
+ per_module.setdefault(d.module or "<no module>", []).append(d)
190
+ for module, decls in per_module.items():
191
+ if len(decls) > MODULE_MAX_DECLARATIONS:
192
+ diags.append(Diagnostic(
193
+ "module_too_large", "warning",
194
+ f"module '{module}' has {len(decls)} declarations "
195
+ f"(budget {MODULE_MAX_DECLARATIONS}); split it",
196
+ decls[0].file, decls[0].line))
197
+
198
+ return sorted(diags, key=lambda x: (x.file, x.line, x.code))
199
+
200
+
201
+ def has_errors(diags: List[Diagnostic]) -> bool:
202
+ return any(d.severity == "error" for d in diags)
memdsl/model.py ADDED
@@ -0,0 +1,184 @@
1
+ """Workspace model: declarations, symbol table, alias resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, Iterable, List, Optional
8
+
9
+ from memdsl.parser import Document, RawDeclaration, parse_file
10
+
11
+ #: Declaration kinds understood by v0.1 typed rules. Other kinds parse
12
+ #: fine but get no kind-specific behavior.
13
+ CORE_KINDS = {
14
+ "entity", "fact", "preference", "boundary", "principle",
15
+ "decision", "state", "open_issue",
16
+ }
17
+
18
+ #: Relation field names recognized inside a `relations { ... }` block
19
+ #: or as top-level fields.
20
+ RELATION_FIELDS = {
21
+ "supports", "refines", "depends_on", "part_of", "supersedes",
22
+ "conflicts_with", "derived_from", "related_to", "revision_of",
23
+ }
24
+
25
+ ACTIVE_STATUSES = {"active"}
26
+ EXCLUDED_STATUSES = {"superseded", "retracted", "archived"}
27
+
28
+
29
+ @dataclass
30
+ class Declaration:
31
+ kind: str
32
+ name: str
33
+ fields: dict
34
+ file: str
35
+ line: int
36
+ module: Optional[str] = None
37
+
38
+ @property
39
+ def id(self) -> str:
40
+ return f"{self.kind}:{self.name}"
41
+
42
+ @property
43
+ def status(self) -> str:
44
+ return str(self.fields.get("status", "active"))
45
+
46
+ @property
47
+ def force(self) -> Optional[str]:
48
+ v = self.fields.get("force")
49
+ return str(v) if v is not None else None
50
+
51
+ @property
52
+ def subject(self) -> Optional[str]:
53
+ v = self.fields.get("subject")
54
+ return str(v) if v is not None else None
55
+
56
+ @property
57
+ def scope(self) -> Optional[str]:
58
+ v = self.fields.get("scope")
59
+ return str(v) if v is not None else None
60
+
61
+ @property
62
+ def claim_text(self) -> str:
63
+ """The primary human-readable statement of this declaration."""
64
+ for key in ("claim", "rule", "decision", "pattern", "summary"):
65
+ v = self.fields.get(key)
66
+ if isinstance(v, str):
67
+ return v
68
+ return ""
69
+
70
+ @property
71
+ def evidence(self) -> Optional[dict]:
72
+ v = self.fields.get("evidence")
73
+ return v if isinstance(v, dict) else None
74
+
75
+ def relations(self) -> Dict[str, List[str]]:
76
+ """All relations declared on this declaration, normalized to lists."""
77
+ out: Dict[str, List[str]] = {}
78
+ sources = [self.fields]
79
+ rel_block = self.fields.get("relations")
80
+ if isinstance(rel_block, dict):
81
+ sources.append(rel_block)
82
+ for src in sources:
83
+ for key, value in src.items():
84
+ if key in RELATION_FIELDS:
85
+ targets = value if isinstance(value, list) else [value]
86
+ out.setdefault(key, []).extend(str(t) for t in targets)
87
+ return out
88
+
89
+ def searchable_text(self) -> str:
90
+ parts = [self.name.replace(".", " "), self.claim_text]
91
+ if self.subject:
92
+ parts.append(self.subject.replace(".", " "))
93
+ for key in ("aliases", "tags", "facets", "exceptions", "canonical_name", "about"):
94
+ v = self.fields.get(key)
95
+ if isinstance(v, list):
96
+ parts.extend(str(x) for x in v)
97
+ elif isinstance(v, str):
98
+ parts.append(v)
99
+ return " ".join(parts).lower()
100
+
101
+
102
+ @dataclass
103
+ class Workspace:
104
+ declarations: List[Declaration] = field(default_factory=list)
105
+ files: List[str] = field(default_factory=list)
106
+
107
+ # ---- construction ----
108
+
109
+ @classmethod
110
+ def load(cls, paths: Iterable[str]) -> "Workspace":
111
+ """Load one or more `.mem` files or directories (recursive)."""
112
+ ws = cls()
113
+ for path in paths:
114
+ if os.path.isdir(path):
115
+ for root, _dirs, names in os.walk(path):
116
+ for name in sorted(names):
117
+ if name.endswith(".mem"):
118
+ ws.add_document(parse_file(os.path.join(root, name)))
119
+ else:
120
+ ws.add_document(parse_file(path))
121
+ return ws
122
+
123
+ def add_document(self, doc: Document) -> None:
124
+ self.files.append(doc.file)
125
+ for raw in doc.declarations:
126
+ self.declarations.append(
127
+ Declaration(kind=raw.kind, name=raw.name, fields=raw.fields,
128
+ file=raw.file, line=raw.line, module=raw.module)
129
+ )
130
+
131
+ # ---- lookups ----
132
+
133
+ def by_id(self, decl_id: str) -> Optional[Declaration]:
134
+ for d in self.declarations:
135
+ if d.id == decl_id or d.name == decl_id:
136
+ return d
137
+ return None
138
+
139
+ def entities(self) -> List[Declaration]:
140
+ return [d for d in self.declarations if d.kind == "entity"]
141
+
142
+ def known_names(self) -> set:
143
+ names = set()
144
+ for d in self.declarations:
145
+ names.add(d.name)
146
+ names.add(d.id)
147
+ return names
148
+
149
+ def known_symbols(self) -> set:
150
+ """Entity names plus their canonical names."""
151
+ symbols = set()
152
+ for e in self.entities():
153
+ symbols.add(e.name)
154
+ cn = e.fields.get("canonical_name")
155
+ if isinstance(cn, str):
156
+ symbols.add(cn)
157
+ # Common implicit subjects
158
+ symbols.add("User")
159
+ return symbols
160
+
161
+ def alias_map(self) -> Dict[str, List[str]]:
162
+ """Lowercased alias -> list of entity symbols it may refer to."""
163
+ amap: Dict[str, List[str]] = {}
164
+ for e in self.entities():
165
+ aliases = e.fields.get("aliases", [])
166
+ if isinstance(aliases, list):
167
+ for a in aliases:
168
+ amap.setdefault(str(a).lower(), []).append(e.name)
169
+ return amap
170
+
171
+ def resolve_alias(self, text: str) -> List[str]:
172
+ """Resolve a natural-language mention to candidate entity symbols."""
173
+ return self.alias_map().get(text.lower(), [])
174
+
175
+ def active(self) -> List[Declaration]:
176
+ return [d for d in self.declarations if d.status not in EXCLUDED_STATUSES]
177
+
178
+ def superseded_ids(self) -> set:
179
+ """Ids/names of declarations that a newer declaration supersedes."""
180
+ out = set()
181
+ for d in self.declarations:
182
+ for target in d.relations().get("supersedes", []):
183
+ out.add(target)
184
+ return out
memdsl/parser.py ADDED
@@ -0,0 +1,235 @@
1
+ """Parser for `.mem` source files.
2
+
3
+ Grammar (v0.1):
4
+
5
+ document := (module_stmt | use_stmt | declaration)*
6
+ module_stmt := 'module' DOTTED_NAME
7
+ use_stmt := 'use' DOTTED_NAME
8
+ declaration := KIND NAME block
9
+ block := '{' entry* '}'
10
+ entry := FIELD ':' value | FIELD block
11
+ value := STRING | ATOM | list
12
+ list := '[' (value (',' value)*)? ']'
13
+
14
+ ATOM covers bare identifiers, dotted symbols, numbers, dates
15
+ (2026-06-15), and call-like scope forms such as project("Aurora"),
16
+ which are kept verbatim as strings.
17
+
18
+ Comments start with '#' and run to end of line.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, List, Optional, Tuple
25
+
26
+
27
+ class ParseError(Exception):
28
+ def __init__(self, message: str, file: str = "<text>", line: int = 0):
29
+ self.file = file
30
+ self.line = line
31
+ super().__init__(f"{file}:{line}: {message}")
32
+
33
+
34
+ @dataclass
35
+ class Token:
36
+ kind: str # 'atom' | 'string' | 'punct'
37
+ value: str
38
+ line: int
39
+
40
+
41
+ _PUNCT = set("{}[]:,")
42
+
43
+
44
+ def _tokenize(text: str, file: str) -> List[Token]:
45
+ tokens: List[Token] = []
46
+ i, line = 0, 1
47
+ n = len(text)
48
+ while i < n:
49
+ ch = text[i]
50
+ if ch == "\n":
51
+ line += 1
52
+ i += 1
53
+ elif ch in " \t\r":
54
+ i += 1
55
+ elif ch == "#":
56
+ while i < n and text[i] != "\n":
57
+ i += 1
58
+ elif ch == '"':
59
+ start_line = line
60
+ i += 1
61
+ buf = []
62
+ while i < n and text[i] != '"':
63
+ if text[i] == "\\" and i + 1 < n:
64
+ esc = text[i + 1]
65
+ buf.append({"n": "\n", "t": "\t", '"': '"', "\\": "\\"}.get(esc, esc))
66
+ i += 2
67
+ else:
68
+ if text[i] == "\n":
69
+ line += 1
70
+ buf.append(text[i])
71
+ i += 1
72
+ if i >= n:
73
+ raise ParseError("unterminated string", file, start_line)
74
+ i += 1
75
+ tokens.append(Token("string", "".join(buf), start_line))
76
+ elif ch in _PUNCT:
77
+ tokens.append(Token("punct", ch, line))
78
+ i += 1
79
+ else:
80
+ start = i
81
+ start_line = line
82
+ while i < n and text[i] not in " \t\r\n#" and text[i] not in _PUNCT and text[i] != '"':
83
+ if text[i] == "(":
84
+ # call-like atom: consume until matching ')'
85
+ depth = 0
86
+ while i < n:
87
+ if text[i] == "(":
88
+ depth += 1
89
+ elif text[i] == ")":
90
+ depth -= 1
91
+ if depth == 0:
92
+ i += 1
93
+ break
94
+ elif text[i] == "\n":
95
+ line += 1
96
+ i += 1
97
+ break
98
+ i += 1
99
+ atom = text[start:i]
100
+ if atom:
101
+ tokens.append(Token("atom", atom, start_line))
102
+ return tokens
103
+
104
+
105
+ @dataclass
106
+ class RawDeclaration:
107
+ kind: str
108
+ name: str
109
+ fields: dict # str -> str | list | dict (nested block)
110
+ file: str
111
+ line: int
112
+ module: Optional[str] = None
113
+
114
+
115
+ @dataclass
116
+ class Document:
117
+ module: Optional[str]
118
+ uses: List[str] = field(default_factory=list)
119
+ declarations: List[RawDeclaration] = field(default_factory=list)
120
+ file: str = "<text>"
121
+
122
+
123
+ class _Parser:
124
+ def __init__(self, tokens: List[Token], file: str):
125
+ self.tokens = tokens
126
+ self.file = file
127
+ self.pos = 0
128
+
129
+ def _peek(self) -> Optional[Token]:
130
+ return self.tokens[self.pos] if self.pos < len(self.tokens) else None
131
+
132
+ def _next(self) -> Token:
133
+ tok = self._peek()
134
+ if tok is None:
135
+ last_line = self.tokens[-1].line if self.tokens else 0
136
+ raise ParseError("unexpected end of file", self.file, last_line)
137
+ self.pos += 1
138
+ return tok
139
+
140
+ def _expect_punct(self, ch: str) -> Token:
141
+ tok = self._next()
142
+ if tok.kind != "punct" or tok.value != ch:
143
+ raise ParseError(f"expected '{ch}', got '{tok.value}'", self.file, tok.line)
144
+ return tok
145
+
146
+ def parse_document(self) -> Document:
147
+ doc = Document(module=None, file=self.file)
148
+ while self._peek() is not None:
149
+ tok = self._next()
150
+ if tok.kind != "atom":
151
+ raise ParseError(f"expected keyword or kind, got '{tok.value}'", self.file, tok.line)
152
+ if tok.value == "module":
153
+ name = self._next()
154
+ if name.kind != "atom":
155
+ raise ParseError("expected module name", self.file, name.line)
156
+ doc.module = name.value
157
+ elif tok.value == "use":
158
+ name = self._next()
159
+ if name.kind != "atom":
160
+ raise ParseError("expected symbol after 'use'", self.file, name.line)
161
+ doc.uses.append(name.value)
162
+ else:
163
+ kind = tok.value
164
+ name_tok = self._next()
165
+ if name_tok.kind != "atom":
166
+ raise ParseError(f"expected declaration name after '{kind}'", self.file, name_tok.line)
167
+ fields = self._parse_block()
168
+ doc.declarations.append(
169
+ RawDeclaration(kind=kind, name=name_tok.value, fields=fields,
170
+ file=self.file, line=tok.line)
171
+ )
172
+ for d in doc.declarations:
173
+ d.module = doc.module
174
+ return doc
175
+
176
+ def _parse_block(self) -> dict:
177
+ self._expect_punct("{")
178
+ fields: dict = {}
179
+ while True:
180
+ tok = self._peek()
181
+ if tok is None:
182
+ raise ParseError("unterminated block", self.file, self.tokens[-1].line)
183
+ if tok.kind == "punct" and tok.value == "}":
184
+ self._next()
185
+ return fields
186
+ key_tok = self._next()
187
+ if key_tok.kind != "atom":
188
+ raise ParseError(f"expected field name, got '{key_tok.value}'", self.file, key_tok.line)
189
+ sep = self._peek()
190
+ if sep is not None and sep.kind == "punct" and sep.value == ":":
191
+ self._next()
192
+ fields[key_tok.value] = self._parse_value()
193
+ elif sep is not None and sep.kind == "punct" and sep.value == "{":
194
+ fields[key_tok.value] = self._parse_block()
195
+ else:
196
+ got = sep.value if sep else "end of file"
197
+ raise ParseError(f"expected ':' or '{{' after '{key_tok.value}', got '{got}'",
198
+ self.file, key_tok.line)
199
+
200
+ def _parse_value(self) -> Any:
201
+ tok = self._peek()
202
+ if tok is None:
203
+ raise ParseError("expected value", self.file, self.tokens[-1].line)
204
+ if tok.kind == "punct" and tok.value == "[":
205
+ return self._parse_list()
206
+ tok = self._next()
207
+ if tok.kind in ("string", "atom"):
208
+ return tok.value
209
+ raise ParseError(f"unexpected value '{tok.value}'", self.file, tok.line)
210
+
211
+ def _parse_list(self) -> List[Any]:
212
+ self._expect_punct("[")
213
+ items: List[Any] = []
214
+ while True:
215
+ tok = self._peek()
216
+ if tok is None:
217
+ raise ParseError("unterminated list", self.file, self.tokens[-1].line)
218
+ if tok.kind == "punct" and tok.value == "]":
219
+ self._next()
220
+ return items
221
+ items.append(self._parse_value())
222
+ tok = self._peek()
223
+ if tok is not None and tok.kind == "punct" and tok.value == ",":
224
+ self._next()
225
+
226
+
227
+ def parse_text(text: str, file: str = "<text>") -> Document:
228
+ """Parse `.mem` source text into a Document."""
229
+ return _Parser(_tokenize(text, file), file).parse_document()
230
+
231
+
232
+ def parse_file(path: str) -> Document:
233
+ """Parse a `.mem` file into a Document."""
234
+ with open(path, "r", encoding="utf-8") as f:
235
+ return parse_text(f.read(), file=path)
memdsl/query.py ADDED
@@ -0,0 +1,268 @@
1
+ """Query executor: retrieve declarations and render a layered EvidencePack.
2
+
3
+ The reference executor is deliberately simple: lowercase term overlap
4
+ plus alias resolution, with typed layering on top. It demonstrates the
5
+ *contract* (query in, MUST/SHOULD/CONTEXT/CONFLICT/MISSING out) --
6
+ production systems should plug in a real retrieval backend (BM25,
7
+ embeddings, or both) behind the same contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from typing import Dict, List, Optional, Tuple
16
+
17
+ from memdsl.model import Workspace, Declaration
18
+
19
+ _WORD_RE = re.compile(r"[a-z0-9_]+")
20
+
21
+ _STOPWORDS = {
22
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "of", "to",
23
+ "in", "on", "at", "for", "and", "or", "not", "no", "my", "me", "i",
24
+ "it", "its", "this", "that", "these", "those", "do", "does", "did",
25
+ "how", "what", "when", "where", "which", "who", "why", "should",
26
+ "would", "could", "can", "will", "with", "about", "into", "over",
27
+ "please", "help", "going", "keep", "get", "make", "your", "you", "we",
28
+ "our", "us", "if", "so", "as", "by", "from", "up", "out", "any",
29
+ }
30
+
31
+
32
+ def _terms(text: str) -> List[str]:
33
+ return [t for t in _WORD_RE.findall(text.lower()) if t not in _STOPWORDS]
34
+
35
+
36
+ @dataclass
37
+ class ScoredDeclaration:
38
+ declaration: Declaration
39
+ score: float
40
+ matched_terms: List[str] = field(default_factory=list)
41
+
42
+
43
+ @dataclass
44
+ class EvidencePack:
45
+ query: str
46
+ must: List[Declaration] = field(default_factory=list)
47
+ should: List[Declaration] = field(default_factory=list)
48
+ context: List[ScoredDeclaration] = field(default_factory=list)
49
+ conflicts: List[Tuple[Declaration, str]] = field(default_factory=list)
50
+ missing: List[str] = field(default_factory=list)
51
+ resolved_subjects: List[str] = field(default_factory=list)
52
+
53
+ # ---- rendering ----
54
+
55
+ def render_text(self) -> str:
56
+ lines: List[str] = []
57
+ if self.resolved_subjects:
58
+ lines.append(f"# resolved subjects: {', '.join(self.resolved_subjects)}")
59
+ lines.append("MUST")
60
+ if self.must:
61
+ for d in self.must:
62
+ lines.append(f"- [{d.id}] {d.claim_text}"
63
+ + (f" (exceptions: {d.fields['exceptions']})"
64
+ if "exceptions" in d.fields else ""))
65
+ else:
66
+ lines.append("- (no hard boundaries apply)")
67
+ lines.append("")
68
+ lines.append("SHOULD")
69
+ if self.should:
70
+ for d in self.should:
71
+ lines.append(f"- [{d.id}] {d.claim_text}")
72
+ else:
73
+ lines.append("- (none)")
74
+ lines.append("")
75
+ lines.append("CONTEXT")
76
+ if self.context:
77
+ for s in self.context:
78
+ d = s.declaration
79
+ extra = ""
80
+ if d.kind == "state" and "as_of" in d.fields:
81
+ extra = f" (as_of {d.fields['as_of']})"
82
+ lines.append(f"- [{d.id}] {d.claim_text}{extra}")
83
+ else:
84
+ lines.append("- (none)")
85
+ if self.conflicts:
86
+ lines.append("")
87
+ lines.append("CONFLICT")
88
+ for d, target in self.conflicts:
89
+ lines.append(f"- [{d.id}] conflicts_with [{target}]")
90
+ if self.missing:
91
+ lines.append("")
92
+ lines.append("MISSING")
93
+ for m in self.missing:
94
+ lines.append(f"- {m}")
95
+ return "\n".join(lines)
96
+
97
+ def render_json(self) -> str:
98
+ def decl(d: Declaration) -> dict:
99
+ return {"id": d.id, "claim": d.claim_text, "force": d.force,
100
+ "scope": d.scope, "status": d.status,
101
+ "file": d.file, "line": d.line}
102
+ return json.dumps({
103
+ "query": self.query,
104
+ "resolved_subjects": self.resolved_subjects,
105
+ "must": [decl(d) for d in self.must],
106
+ "should": [decl(d) for d in self.should],
107
+ "context": [dict(decl(s.declaration), score=round(s.score, 3))
108
+ for s in self.context],
109
+ "conflicts": [{"id": d.id, "conflicts_with": t}
110
+ for d, t in self.conflicts],
111
+ "missing": self.missing,
112
+ }, indent=2, ensure_ascii=False)
113
+
114
+
115
+ def _score(decl: Declaration, query_terms: List[str],
116
+ subject_hits: List[str]) -> ScoredDeclaration:
117
+ hay = decl.searchable_text()
118
+ hay_terms = set(_terms(hay))
119
+ matched = [t for t in set(query_terms) if t in hay_terms]
120
+ score = float(len(matched))
121
+ if decl.subject and decl.subject in subject_hits:
122
+ score += 2.0
123
+ matched.append(f"subject:{decl.subject}")
124
+ if score > 0 and str(decl.fields.get("confidence", "")) == "high":
125
+ score += 0.25
126
+ return ScoredDeclaration(decl, score, matched)
127
+
128
+
129
+ def build_evidence_pack(
130
+ ws: Workspace,
131
+ query: str,
132
+ kinds: Optional[List[str]] = None,
133
+ subject: Optional[str] = None,
134
+ limit: int = 8,
135
+ ) -> EvidencePack:
136
+ """Run a query against the workspace and build a layered EvidencePack."""
137
+ pack = EvidencePack(query=query)
138
+ query_terms = _terms(query)
139
+
140
+ # alias resolution: map query words/phrases to entity symbols
141
+ amap = ws.alias_map()
142
+ subject_hits: List[str] = []
143
+ lowered = query.lower()
144
+ for alias, symbols in amap.items():
145
+ if alias in lowered:
146
+ subject_hits.extend(symbols)
147
+ if subject:
148
+ subject_hits.append(subject)
149
+ pack.resolved_subjects = sorted(set(subject_hits))
150
+
151
+ superseded = ws.superseded_ids()
152
+ candidates = [
153
+ d for d in ws.active()
154
+ if d.kind != "entity"
155
+ and d.id not in superseded and d.name not in superseded
156
+ and (kinds is None or d.kind in kinds)
157
+ and (subject is None or d.subject == subject)
158
+ ]
159
+
160
+ scored = [_score(d, query_terms, subject_hits) for d in candidates]
161
+ hits = sorted([s for s in scored if s.score > 0],
162
+ key=lambda s: -s.score)[:limit]
163
+ hit_ids = {s.declaration.id for s in hits}
164
+ hit_subjects = {s.declaration.subject for s in hits if s.declaration.subject}
165
+ hit_scopes = {s.declaration.scope for s in hits if s.declaration.scope}
166
+
167
+ # MUST: hard boundaries that matched, or that share subject/scope
168
+ # with the matched declarations, or that are global.
169
+ for d in ws.active():
170
+ if d.kind != "boundary" or d.force not in (None, "hard"):
171
+ continue
172
+ relevant = (
173
+ d.id in hit_ids
174
+ or d.scope in hit_scopes
175
+ or d.subject in hit_subjects
176
+ or d.scope == "global"
177
+ )
178
+ if relevant:
179
+ pack.must.append(d)
180
+
181
+ must_ids = {d.id for d in pack.must}
182
+
183
+ # SHOULD: strong preferences and active principles among the hits
184
+ for s in hits:
185
+ d = s.declaration
186
+ if d.id in must_ids:
187
+ continue
188
+ if (d.kind == "preference" and d.force == "strong") or d.kind == "principle":
189
+ pack.should.append(d)
190
+
191
+ should_ids = {d.id for d in pack.should}
192
+
193
+ # CONTEXT: everything else that matched. `open_issue` never enters
194
+ # CONTEXT -- unresolved questions are gaps, not facts (SPEC §7 rule 3);
195
+ # they surface under MISSING below.
196
+ for s in hits:
197
+ d = s.declaration
198
+ if d.id in must_ids or d.id in should_ids:
199
+ continue
200
+ if d.kind == "open_issue":
201
+ continue
202
+ pack.context.append(s)
203
+
204
+ # CONFLICT: declared conflicts among selected declarations
205
+ selected = pack.must + pack.should + [s.declaration for s in pack.context]
206
+ selected_ids = {d.id for d in selected} | {d.name for d in selected}
207
+ for d in selected:
208
+ for target in d.relations().get("conflicts_with", []):
209
+ bare = target.split(":", 1)[-1]
210
+ if target in selected_ids or bare in selected_ids:
211
+ pack.conflicts.append((d, target))
212
+
213
+ # MISSING: explicit gaps
214
+ if not hits:
215
+ pack.missing.append(f"no declarations matched query terms: {query_terms}")
216
+ if subject and not any(d.subject == subject for d in selected):
217
+ pack.missing.append(f"no declarations found for subject '{subject}'")
218
+ for s in scored:
219
+ if s.declaration.kind == "open_issue" and s.score > 0:
220
+ pack.missing.append(
221
+ f"open issue [{s.declaration.id}]: {s.declaration.claim_text}")
222
+
223
+ return pack
224
+
225
+
226
+ def explain(ws: Workspace, decl_id: str) -> str:
227
+ """Render one declaration with its relations and evidence."""
228
+ d = ws.by_id(decl_id)
229
+ if d is None:
230
+ return f"declaration '{decl_id}' not found"
231
+ lines = [
232
+ f"{d.id}",
233
+ f" file: {d.file}:{d.line}",
234
+ f" module: {d.module or '(none)'}",
235
+ f" status: {d.status}",
236
+ ]
237
+ if d.subject:
238
+ lines.append(f" subject: {d.subject}")
239
+ if d.force:
240
+ lines.append(f" force: {d.force}")
241
+ if d.scope:
242
+ lines.append(f" scope: {d.scope}")
243
+ if d.claim_text:
244
+ lines.append(f" claim: {d.claim_text}")
245
+ rels = d.relations()
246
+ if rels:
247
+ lines.append(" relations:")
248
+ for rel, targets in rels.items():
249
+ for t in targets:
250
+ lines.append(f" {rel} -> {t}")
251
+ # reverse relations
252
+ reverse = []
253
+ for other in ws.declarations:
254
+ if other.id == d.id:
255
+ continue
256
+ for rel, targets in other.relations().items():
257
+ for t in targets:
258
+ if t in (d.id, d.name):
259
+ reverse.append(f" {other.id} --{rel}--> this")
260
+ if reverse:
261
+ lines.append(" referenced by:")
262
+ lines.extend(reverse)
263
+ ev = d.evidence
264
+ if ev:
265
+ lines.append(" evidence:")
266
+ for k, v in ev.items():
267
+ lines.append(f" {k}: {v}")
268
+ return "\n".join(lines)
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: memdsl
3
+ Version: 0.1.0
4
+ Summary: Agent memory as normative source code: a lintable, queryable memory DSL for LLM agents
5
+ Author: liyuan
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: agent,context-engineering,dsl,llm,memory
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.9
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # memdsl
20
+
21
+ [English](#english) | [中文](#中文)
22
+
23
+ ## English
24
+
25
+ **Agent memory as normative source code.**
26
+
27
+ Most agent memory systems store what you said. Almost none of them
28
+ distinguish what an agent *must obey* from what it *may consider*. memdsl
29
+ treats long-term memory as a small, typed, lintable source language —
30
+ `.mem` files you can read, review, diff, version, and test — where a
31
+ `boundary` is a rule to enforce, not a fact to recall.
32
+
33
+ ```mem
34
+ preference schedule.deep_work_mornings {
35
+ subject: User
36
+ claim: "Prefers deep work in the morning; meetings after 2pm."
37
+ force: strong
38
+ scope: scheduling
39
+ evidence { source: chat quote: "Stop booking me into morning meetings." }
40
+ }
41
+
42
+ boundary privacy.no_family_in_public {
43
+ subject: User
44
+ rule: "Never include family member names or details in public-facing content."
45
+ force: hard
46
+ scope: global
47
+ exceptions: [user_explicit_override]
48
+ evidence { source: chat quote: "Anything about my family stays out of blog posts. Always." }
49
+ }
50
+ ```
51
+
52
+ The difference matters: a preference shapes suggestions; a boundary binds.
53
+ Flat embeddings of "user said X" erase exactly this distinction, which is
54
+ why agents violate rules their memory technically "contains".
55
+
56
+ ### 30 seconds: same question, one declaration apart
57
+
58
+ Ask an agent to *"draft a public blog post about building Aurora as a
59
+ working parent."* A similarity-ranked memory gives it the old chats where
60
+ the kids' names appear — and nothing that ranks a rule above a reminiscence.
61
+ The retrieved snippets are all just CONTEXT, so the names end up in the post:
62
+
63
+ ```text
64
+ retrieved: "...shipped the sync fix after bedtime, Theo finally sleeping..."
65
+ retrieved: "aurora beta feedback thread..."
66
+ ```
67
+
68
+ With the boundary declared, the evidence pack puts the rule where it cannot
69
+ be missed — in MUST, above every piece of context, whether or not the query
70
+ lexically matched it:
71
+
72
+ ```console
73
+ $ memdsl query examples/alex/ -q "draft a public blog post about aurora"
74
+ MUST
75
+ - [boundary:privacy.no_family_in_public] Never include family member names
76
+ or details in public-facing content. (exceptions: ['user_explicit_override'])
77
+
78
+ CONTEXT
79
+ - [state:aurora.beta_progress] Private beta with 40 testers; ...
80
+ ```
81
+
82
+ Feed the pack to the LLM as context and the compliant behavior is to write
83
+ the post *without* family details — and to be able to cite
84
+ `boundary:privacy.no_family_in_public` as the reason. The pack is the
85
+ prompt; the MUST layer is what changes the answer.
86
+
87
+ ### Lint it, query it
88
+
89
+ Memory that can be wrong deserves a linter:
90
+
91
+ ```console
92
+ $ memdsl lint examples/lint-demo/
93
+ broken.mem:7: error[unresolved_symbol] subject 'User.Barista' is not a declared entity
94
+ broken.mem:20: error[missing_evidence] active fact 'home.timezone' has no evidence block
95
+ broken.mem:28: warning[type_force_mismatch] preference uses force: hard; promote it to a boundary
96
+ broken.mem:41: warning[boundary_without_exception] confirm it is truly unconditional
97
+ broken.mem:53: warning[stale_state] as_of 2025-11-02 is older than 180 days
98
+
99
+ 6 declarations, 2 error(s), 3 warning(s)
100
+ ```
101
+
102
+ Queries return a **layered evidence pack**, not a hit list. Hard
103
+ boundaries surface even when the query doesn't lexically match them;
104
+ declared conflicts are shown, not averaged away; open issues surface as
105
+ gaps instead of being hallucinated over:
106
+
107
+ ```console
108
+ $ memdsl query examples/alex/ -q "should aurora keep the free tier"
109
+ # resolved subjects: Project.Aurora
110
+ MUST
111
+ - [boundary:privacy.no_family_in_public] Never include family member names ...
112
+
113
+ SHOULD
114
+ - (none)
115
+
116
+ CONTEXT
117
+ - [decision:aurora.pricing_free_tier] Keep a permanent free tier.
118
+ - [state:aurora.beta_progress] Private beta with 40 testers; sync is the top complaint. (as_of 2026-06-20)
119
+ - [goal:aurora.revenue_target_2026] Reach $2k MRR from Aurora by end of 2026.
120
+
121
+ CONFLICT
122
+ - [decision:aurora.pricing_free_tier] conflicts_with [aurora.revenue_target_2026]
123
+
124
+ MISSING
125
+ - open issue [open_issue:aurora.pricing_undecided]: Paid tier pricing is undecided: $5 flat vs usage-based.
126
+ ```
127
+
128
+ Feed that pack — MUST/SHOULD/CONTEXT/CONFLICT/MISSING — to your LLM as
129
+ context. Every line carries a declaration id, so answers are citable and
130
+ auditable back to source and evidence.
131
+
132
+ ### Install
133
+
134
+ ```console
135
+ pip install memdsl # or: pip install -e . from a checkout
136
+ memdsl lint examples/alex/
137
+ memdsl query examples/alex/ -q "plan tomorrow morning"
138
+ memdsl explain examples/alex/ decision:aurora.db_postgres_migration
139
+ ```
140
+
141
+ Zero runtime dependencies. Python 3.9+.
142
+
143
+ Note: `memdsl lint examples/alex/` reports **one intentional warning** —
144
+ Alex's `schedule.no_meetings_before_10` boundary declares no `exceptions`,
145
+ and the linter asks you to confirm it is truly unconditional. That nudge
146
+ is the feature; a clean run is `examples/mira/`.
147
+
148
+ ### What's in the box
149
+
150
+ - **A tiny declarative language** (`.mem`): typed declarations
151
+ (`entity`, `fact`, `preference`, `boundary`, `principle`, `decision`,
152
+ `state`, `open_issue`) with force, scope, evidence, relations
153
+ (`supersedes`, `conflicts_with`, `refines`, ...), and lifecycle status.
154
+ Full grammar and semantics in [docs/SPEC.md](docs/SPEC.md).
155
+ - **A linter** with ten diagnostics: dangling symbols, missing evidence,
156
+ ambiguous aliases, stale states, boundaries without exceptions,
157
+ preferences masquerading as laws, unmarked supersede chains.
158
+ - **A query executor** implementing the EvidencePack contract, plus
159
+ `explain` for tracing one declaration's relations and provenance.
160
+ - **Synthetic example personas** (`examples/`) — fictional users "Alex"
161
+ and "Mira" — and a deliberately broken file for the linter demo.
162
+
163
+ ### What memdsl is *not*
164
+
165
+ - **Not a replacement for [Mem0](https://github.com/mem0ai/mem0),
166
+ [Zep](https://www.getzep.com/), or
167
+ [LangMem](https://langchain-ai.github.io/langmem/).** Those are
168
+ retrieval/extraction platforms. memdsl is a *source format and
169
+ contract* for the layer above: what memory means, how strongly it
170
+ binds, and how it is maintained. You could compile `.mem` files into
171
+ any of them.
172
+ - **Not a retrieval engine.** The reference executor is deliberately
173
+ naive lexical matching — enough to demonstrate the contract. Production
174
+ use should plug BM25/embeddings behind the same EvidencePack interface.
175
+ Do not benchmark toy retrieval and conclude the format failed.
176
+ - **Not an auto-writer.** v0.1 has no write pipeline. The spec (§10)
177
+ describes a gated, human-reviewed write path; automation should be
178
+ earned with audited metrics, not assumed.
179
+
180
+ ### Does the approach work?
181
+
182
+ Early evidence from the private system this was extracted from
183
+ (DigitalSelf, single-user): on a 100-question eval over the author's real
184
+ long-term memory, DSL-structured retrieval nearly doubled top-1 precision
185
+ against the same system's tuned RAG baseline (**0.57 vs 0.30**; hit rate
186
+ 0.67 vs 0.53). On public conversational benchmarks (LongMemEval, LoCoMo)
187
+ it performs at parity with baselines under a retrieval-only harness whose
188
+ target mapping is still being audited — we explicitly do *not* claim
189
+ public-benchmark wins. Current honest costs: seconds-level query latency
190
+ at scale in the private implementation (being moved to write-time
191
+ compilation), and n=1 personalization. A reproducible benchmark report
192
+ will be published separately.
193
+
194
+ The interesting unmeasured dimension — and the reason this exists — is
195
+ **compliance, not recall**: existing memory benchmarks test whether an
196
+ agent can *find* a fact, none test whether it *respects* a boundary. A
197
+ boundary-compliance benchmark is the roadmap's centerpiece.
198
+
199
+ ### Related work
200
+
201
+ Typed/structured agent memory is converging fast:
202
+ [MemIR](https://arxiv.org/abs/2605.25869) (typed memory IR, provenance-role
203
+ separation), [Zep/Graphiti](https://www.getzep.com/) (temporal knowledge
204
+ graphs with fact validity windows), [A-Mem](https://arxiv.org/abs/2502.12110)
205
+ (Zettelkasten-style linked notes), [MemOS](https://arxiv.org/abs/2507.03724)
206
+ (memory scheduling), and the `CLAUDE.md`/`AGENTS.md` culture of local,
207
+ reviewable context files. memdsl's position in that landscape: local-first
208
+ plain-text source, an explicit **normative layer** (force + boundaries +
209
+ exceptions + MUST/SHOULD rendering), and code-style diagnostics as a
210
+ first-class surface.
211
+
212
+ ### Roadmap
213
+
214
+ - Target-mapping audit + reproducible benchmark report
215
+ - Boundary-compliance benchmark (does the agent *respect* MUST items?)
216
+ - Pluggable retrieval backends (BM25, embeddings) behind the EvidencePack contract
217
+ - Module directory compilation for query planning
218
+ - Gated write pipeline with review queue
219
+
220
+ ---
221
+
222
+ Today, memdsl defines memory as typed, auditable source code. Future
223
+ runtimes can navigate these declarations the way developers navigate
224
+ code — following relations, inspecting evidence, tracing history, and
225
+ asking for missing information instead of guessing.
226
+
227
+ ### License
228
+
229
+ Code: [MIT](LICEN
@@ -0,0 +1,11 @@
1
+ memdsl/__init__.py,sha256=gyr0Obz6_9qcojPbgCC4aReZyPKrXBMeZ7G3D1_3yP0,745
2
+ memdsl/cli.py,sha256=slNzw4Tltz-hjtoloYane24u7vO8IOP7ANnMldmMaNc,3245
3
+ memdsl/linter.py,sha256=5W4TwnpwQ_OlA7LiXeiSWKfe6vb2NOYqVhXlV5T8Pb4,8145
4
+ memdsl/model.py,sha256=a9SuBSGOXsPI7AKeaZh9clIF3XdqQTfLEG23kMMTbe0,6118
5
+ memdsl/parser.py,sha256=7zICGPEUoo93gZyJjHZwBAS1qSz5yx4fCgyB7Cx_aOk,8150
6
+ memdsl/query.py,sha256=unsdzd4bJvvl7dkUbDaM1KSNlMwDXyiGOwSPkoal9Co,9752
7
+ memdsl-0.1.0.dist-info/METADATA,sha256=VkcnReq5TZpe_GsySLXzoxxkzqzNv-OzJabQ-IQCXhw,9466
8
+ memdsl-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ memdsl-0.1.0.dist-info/entry_points.txt,sha256=R2fMjKk1zYU9v-oHaWwpmXef2eilkjNerN-8qCwl-4U,43
10
+ memdsl-0.1.0.dist-info/licenses/LICENSE,sha256=rZD-6-PAHBEUYb6QrwNRD__y7xRMFzMFygSqTrb80_U,1181
11
+ memdsl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ memdsl = memdsl.cli:main
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 liyuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ The specification document (docs/SPEC.md) is licensed separately under
26
+ CC-BY-4.0. See docs/SPEC.md for details.