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 +27 -0
- memdsl/cli.py +89 -0
- memdsl/linter.py +202 -0
- memdsl/model.py +184 -0
- memdsl/parser.py +235 -0
- memdsl/query.py +268 -0
- memdsl-0.1.0.dist-info/METADATA +229 -0
- memdsl-0.1.0.dist-info/RECORD +11 -0
- memdsl-0.1.0.dist-info/WHEEL +4 -0
- memdsl-0.1.0.dist-info/entry_points.txt +2 -0
- memdsl-0.1.0.dist-info/licenses/LICENSE +26 -0
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,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.
|