memware 0.1.1__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.
memware/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """memware — memory for AI agents that only remembers the latest truth.
2
+
3
+ Two stores, one SQLite file:
4
+
5
+ * **turns** — immutable evidence: what was said in past sessions, indexed with
6
+ FTS5 for cheap, model-free recall.
7
+ * **beliefs** — a bi-temporal ledger of facts. A new value for the same
8
+ ``(subject, relation)`` key supersedes the old one; recall only ever returns
9
+ the currently valid belief. History is kept for audit, never surfaced.
10
+ """
11
+
12
+ from memware.ledger import Outcome, Policy, approve, assert_belief, current, history, reject
13
+ from memware.store import Store
14
+
15
+ __all__ = [
16
+ "Outcome",
17
+ "Policy",
18
+ "Store",
19
+ "approve",
20
+ "assert_belief",
21
+ "current",
22
+ "history",
23
+ "reject",
24
+ ]
25
+ __version__ = "0.1.1"
memware/cli.py ADDED
@@ -0,0 +1,311 @@
1
+ """``memware`` command-line interface. Every command is also usable from a hook:
2
+ pass ``--from-hook`` to read the harness's JSON payload on stdin."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from memware import __version__
12
+ from memware.index import (
13
+ read_turns,
14
+ search_beliefs,
15
+ search_beliefs_multi,
16
+ search_turns_multi,
17
+ )
18
+ from memware.ingest import capture_disabled, prune_sources, sync_file, sync_tree
19
+ from memware.ledger import Policy, approve, assert_belief, current, history, reject
20
+ from memware.review import HttpReviewBackend, JsonlReviewBackend, open_reviews, sync_reviews
21
+ from memware.store import DEFAULT_DB, Store
22
+
23
+
24
+ def _hook_payload() -> dict[str, object]:
25
+ try:
26
+ payload = json.load(sys.stdin) if not sys.stdin.isatty() else {}
27
+ except (json.JSONDecodeError, ValueError):
28
+ return {}
29
+ return payload if isinstance(payload, dict) else {}
30
+
31
+
32
+ def _out(obj: object, as_json: bool) -> None:
33
+ if as_json:
34
+ print(json.dumps(obj, indent=2, default=str))
35
+ elif isinstance(obj, list):
36
+ for row in obj:
37
+ print(row if isinstance(row, str) else json.dumps(row, default=str))
38
+ else:
39
+ print(obj if isinstance(obj, str) else json.dumps(obj, default=str))
40
+
41
+
42
+ def cmd_init(a: argparse.Namespace) -> int:
43
+ with Store(a.db) as s:
44
+ _out({"db": str(s.path), **s.stats()}, a.json)
45
+ return 0
46
+
47
+
48
+ def cmd_sync(a: argparse.Namespace) -> int:
49
+ if a.from_hook and capture_disabled():
50
+ return 0 # MEMWARE_NO_CAPTURE=1: this run must not enter the store
51
+ paths = list(a.paths)
52
+ if a.from_hook:
53
+ tp = _hook_payload().get("transcript_path")
54
+ if tp:
55
+ paths.append(str(tp))
56
+ if not paths:
57
+ print("nothing to sync", file=sys.stderr)
58
+ return 0
59
+ with Store(a.db) as s:
60
+ report: dict[str, int] = {}
61
+ for p in paths:
62
+ path = Path(p).expanduser()
63
+ if path.is_dir():
64
+ report.update(
65
+ sync_tree(
66
+ s,
67
+ path,
68
+ harness=a.harness,
69
+ skip_if_contains=a.skip_if_contains,
70
+ exclude=a.exclude,
71
+ )
72
+ )
73
+ elif path.exists():
74
+ report[str(path)] = sync_file(
75
+ s, path, harness=a.harness, skip_if_contains=a.skip_if_contains
76
+ )
77
+ _out({"added": sum(report.values()), "files": len(report)}, a.json or a.from_hook)
78
+ return 0
79
+
80
+
81
+ def cmd_recall(a: argparse.Namespace) -> int:
82
+ with Store(a.db) as s:
83
+ hits = []
84
+ if a.what in ("all", "beliefs"):
85
+ hits += search_beliefs_multi(s, a.queries, k=a.k, record_use=not a.no_touch)
86
+ if a.what in ("all", "turns"):
87
+ hits += search_turns_multi(
88
+ s, a.queries, k=a.k, record_use=not a.no_touch, snippet_tokens=a.snippet_tokens
89
+ )
90
+ rows = [
91
+ {
92
+ "kind": h.kind,
93
+ "id": h.id,
94
+ "score": round(h.score, 4),
95
+ "session": h.session,
96
+ "ts": h.ts,
97
+ "role": h.role,
98
+ "subject": h.subject,
99
+ "relation": h.relation,
100
+ "source": h.source,
101
+ "offset": h.offset,
102
+ "snippet": h.snippet,
103
+ "text": h.text if a.full else h.text[:300],
104
+ }
105
+ for h in hits
106
+ ]
107
+ _out(rows, a.json)
108
+ return 0
109
+
110
+
111
+ def cmd_context(a: argparse.Namespace) -> int:
112
+ """Prompt-time helper: print currently valid beliefs relevant to the prompt."""
113
+ prompt = a.prompt or str(_hook_payload().get("prompt", ""))
114
+ if not prompt.strip():
115
+ return 0
116
+ with Store(a.db) as s:
117
+ hits = search_beliefs(s, prompt, k=a.k, require_subject=True)
118
+ if not hits:
119
+ return 0
120
+ lines = []
121
+ for h in hits:
122
+ value = h.text.removeprefix(f"{h.subject} {h.relation} ")
123
+ since = f" (since {h.ts[:10]})" if h.ts else ""
124
+ lines.append(f"- {h.subject} {h.relation}: {value}{since}")
125
+ block = "Known facts (currently valid, from your memory ledger):\n" + "\n".join(lines)
126
+ if a.from_hook:
127
+ print(
128
+ json.dumps(
129
+ {
130
+ "hookSpecificOutput": {
131
+ "hookEventName": "UserPromptSubmit",
132
+ "additionalContext": block,
133
+ }
134
+ }
135
+ )
136
+ )
137
+ else:
138
+ print(block)
139
+ return 0
140
+
141
+
142
+ def cmd_assert(a: argparse.Namespace) -> int:
143
+ with Store(a.db) as s:
144
+ r = assert_belief(
145
+ s,
146
+ a.subject,
147
+ a.relation,
148
+ a.value,
149
+ valid_from=a.valid_from,
150
+ source=a.source,
151
+ reliability=a.reliability,
152
+ policy=Policy(a.policy),
153
+ )
154
+ _out(
155
+ {
156
+ "outcome": r.outcome.value,
157
+ "belief_id": r.belief_id,
158
+ "incumbent_id": r.incumbent_id,
159
+ "review_id": r.review_id,
160
+ },
161
+ a.json,
162
+ )
163
+ return 0
164
+
165
+
166
+ def cmd_beliefs(a: argparse.Namespace) -> int:
167
+ with Store(a.db) as s:
168
+ rows = history(s, a.subject, a.relation) if a.relation else current(s, a.subject)
169
+ _out(rows, a.json)
170
+ return 0
171
+
172
+
173
+ def cmd_read(a: argparse.Namespace) -> int:
174
+ with Store(a.db) as s:
175
+ _out(read_turns(s, a.session, around=a.around, window=a.window), a.json)
176
+ return 0
177
+
178
+
179
+ def cmd_review(a: argparse.Namespace) -> int:
180
+ with Store(a.db) as s:
181
+ if a.action == "list":
182
+ _out([r.__dict__ for r in open_reviews(s)], a.json)
183
+ elif a.action == "approve":
184
+ _out(approve(s, a.id).__dict__, a.json)
185
+ elif a.action == "reject":
186
+ _out(reject(s, a.id).__dict__, a.json)
187
+ elif a.action == "sync":
188
+ backend: HttpReviewBackend | JsonlReviewBackend = (
189
+ HttpReviewBackend(a.url, a.token)
190
+ if a.url
191
+ else JsonlReviewBackend(a.outbox, a.inbox)
192
+ )
193
+ _out(sync_reviews(s, backend), a.json)
194
+ return 0
195
+
196
+
197
+ def cmd_prune(a: argparse.Namespace) -> int:
198
+ with Store(a.db) as s:
199
+ rep = prune_sources(s, glob=a.glob, containing=a.containing)
200
+ _out({"sources_pruned": len(rep), "turns_removed": sum(rep.values())}, a.json)
201
+ return 0
202
+
203
+
204
+ def cmd_stats(a: argparse.Namespace) -> int:
205
+ with Store(a.db) as s:
206
+ _out({"db": str(s.path), **s.stats()}, a.json)
207
+ return 0
208
+
209
+
210
+ def build_parser() -> argparse.ArgumentParser:
211
+ p = argparse.ArgumentParser(prog="memware", description=__doc__)
212
+ p.add_argument("--db", default=str(DEFAULT_DB), help="SQLite file (env MEMWARE_DB)")
213
+ p.add_argument("--json", action="store_true", help="machine-readable output")
214
+ p.add_argument("--version", action="version", version=__version__)
215
+ sub = p.add_subparsers(dest="cmd", required=True)
216
+
217
+ def add(name: str, help: str) -> argparse.ArgumentParser:
218
+ sp = sub.add_parser(name, help=help)
219
+ sp.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
220
+ return sp
221
+
222
+ s = add("init", "create the database")
223
+ s.set_defaults(fn=cmd_init)
224
+
225
+ s = add("sync", "index new turns from transcripts")
226
+ s.add_argument("paths", nargs="*")
227
+ s.add_argument("--harness", default="claude-code")
228
+ s.add_argument("--from-hook", action="store_true")
229
+ s.add_argument(
230
+ "--skip-if-contains",
231
+ metavar="TEXT",
232
+ help="skip (and un-index) files whose head contains TEXT, e.g. an eval marker",
233
+ )
234
+ s.add_argument(
235
+ "--exclude",
236
+ action="append",
237
+ default=[],
238
+ metavar="GLOB",
239
+ help="path glob to skip when syncing a directory (repeatable)",
240
+ )
241
+ s.set_defaults(fn=cmd_sync)
242
+
243
+ s = add("recall", "search turns and beliefs; pass several phrasings to fuse them")
244
+ s.add_argument(
245
+ "queries",
246
+ nargs="+",
247
+ metavar="QUERY",
248
+ help="one or more phrasings: synonyms, related terms, the literal value you expect",
249
+ )
250
+ s.add_argument("-k", type=int, default=8)
251
+ s.add_argument("--what", choices=["all", "turns", "beliefs"], default="all")
252
+ s.add_argument("--full", action="store_true")
253
+ s.add_argument("--no-touch", action="store_true")
254
+ s.add_argument("--snippet-tokens", type=int, default=96, help="FTS5 snippet window (tokens)")
255
+ s.set_defaults(fn=cmd_recall)
256
+
257
+ s = add("context", "print valid beliefs relevant to a prompt (hook-friendly)")
258
+ s.add_argument("prompt", nargs="?")
259
+ s.add_argument("-k", type=int, default=6)
260
+ s.add_argument("--from-hook", action="store_true")
261
+ s.set_defaults(fn=cmd_context)
262
+
263
+ s = add("assert", "record a belief; supersedes the previous value")
264
+ s.add_argument("subject")
265
+ s.add_argument("relation")
266
+ s.add_argument("value")
267
+ s.add_argument("--valid-from")
268
+ s.add_argument("--source")
269
+ s.add_argument("--reliability", type=float, default=0.5)
270
+ s.add_argument(
271
+ "--policy", choices=[x.value for x in Policy], default=Policy.GATE_CONFLICTS.value
272
+ )
273
+ s.set_defaults(fn=cmd_assert)
274
+
275
+ s = add("beliefs", "current beliefs, or the history of one key")
276
+ s.add_argument("subject", nargs="?")
277
+ s.add_argument("relation", nargs="?")
278
+ s.set_defaults(fn=cmd_beliefs)
279
+
280
+ s = add("read", "read a session's turns")
281
+ s.add_argument("session")
282
+ s.add_argument("--around", type=int)
283
+ s.add_argument("--window", type=int, default=5)
284
+ s.set_defaults(fn=cmd_read)
285
+
286
+ s = add("review", "list/approve/reject/sync contested supersessions")
287
+ s.add_argument("action", choices=["list", "approve", "reject", "sync"])
288
+ s.add_argument("id", nargs="?", type=int)
289
+ s.add_argument("--outbox", default="~/.memware/review-outbox.jsonl")
290
+ s.add_argument("--inbox", default="~/.memware/review-inbox.jsonl")
291
+ s.add_argument("--url")
292
+ s.add_argument("--token")
293
+ s.set_defaults(fn=cmd_review)
294
+
295
+ s = add("prune", "un-index sources by path glob and/or content marker")
296
+ s.add_argument("--glob", metavar="GLOB")
297
+ s.add_argument("--containing", metavar="TEXT")
298
+ s.set_defaults(fn=cmd_prune)
299
+
300
+ s = add("stats", "counts")
301
+ s.set_defaults(fn=cmd_stats)
302
+ return p
303
+
304
+
305
+ def main(argv: list[str] | None = None) -> int:
306
+ a = build_parser().parse_args(argv)
307
+ return int(a.fn(a))
308
+
309
+
310
+ if __name__ == "__main__":
311
+ raise SystemExit(main())
memware/eval.py ADDED
@@ -0,0 +1,208 @@
1
+ """Retrieval-level evaluation: does recall surface the right evidence, and never the stale value?
2
+
3
+ A question set is JSONL, one object per line::
4
+
5
+ {"id": "q1", "question": "which port does the api listen on",
6
+ "expect_any": ["8443"], "not_expect": ["8080"], "type": "stale"}
7
+
8
+ ``type`` is ``fact`` (answer exists), ``stale`` (answer changed over time; the
9
+ old value must not appear) or ``negative`` (nothing relevant should be found).
10
+ Scores are containment against the retrieved context, so no model is needed
11
+ and results are reproducible. Two contexts are scored for every question:
12
+ ``beliefs`` (currently valid beliefs only — what a prompt-time hook injects)
13
+ and ``beliefs+turns`` (beliefs plus transcript evidence). Transcripts are
14
+ evidence and legitimately contain old values, so a stale value appearing in
15
+ ``beliefs+turns`` is expected; appearing in ``beliefs`` is a defect. End-to-end
16
+ runs with a model are described in docs/eval.md.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import statistics
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from memware.index import search_beliefs, search_turns
30
+ from memware.ingest import sync_tree
31
+ from memware.store import DEFAULT_DB, Store
32
+
33
+ MARKER = "[memware-eval]"
34
+ """Put this in every prompt an evaluation sends. Runs that carry it are excluded when
35
+ ``--corpus`` rebuilds a clean store, and ``MEMWARE_NO_CAPTURE=1`` in the run's environment
36
+ keeps hooks from indexing them in the first place."""
37
+
38
+
39
+ def build_clean_store(
40
+ db: str,
41
+ corpus: Path,
42
+ *,
43
+ harness: str = "claude-code",
44
+ beliefs_from: str | None = None,
45
+ exclude: list[str] | None = None,
46
+ also_skip: list[str] | None = None,
47
+ ) -> dict[str, int]:
48
+ """Index ``corpus`` into a fresh store at ``db``, skipping files that contain MARKER
49
+ (or any of ``also_skip`` — e.g. the prompt text of an older evaluation harness),
50
+ and copy the belief ledger from ``beliefs_from`` so retrieval is judged against the
51
+ same beliefs the live system has."""
52
+ for suffix in ("", "-wal", "-shm"):
53
+ Path(db + suffix).unlink(missing_ok=True)
54
+ with Store(db) as s:
55
+ rep = sync_tree(
56
+ s,
57
+ corpus,
58
+ harness=harness,
59
+ skip_if_contains=[MARKER, *(also_skip or [])],
60
+ exclude=exclude,
61
+ )
62
+ copied = 0
63
+ if beliefs_from:
64
+ s.conn.execute("ATTACH ? AS live", (str(Path(beliefs_from).expanduser()),))
65
+ s.conn.execute("INSERT INTO belief SELECT * FROM live.belief")
66
+ copied = int(s.conn.execute("SELECT count(*) FROM belief").fetchone()[0])
67
+ s.conn.execute("DETACH live")
68
+ return {"files": len(rep), "turns": sum(rep.values()), "beliefs": copied}
69
+
70
+
71
+ def retrieve(store: Store, question: str, k: int) -> tuple[str, str]:
72
+ """Return (beliefs_context, beliefs_plus_turns_context).
73
+
74
+ The beliefs context mirrors what the prompt-time hook injects, so it uses the
75
+ same subject gate (``require_subject=True``); the turns context mirrors an
76
+ explicit, broad recall.
77
+ """
78
+ beliefs = search_beliefs(store, question, k=k, record_use=False, require_subject=True)
79
+ turns = search_turns(store, question, k=k, record_use=False)
80
+ b = "\n".join(h.text for h in beliefs)
81
+ return b, "\n".join([b, *(h.text for h in turns)]).strip()
82
+
83
+
84
+ def _first(ctx: str, needles: list[Any]) -> int:
85
+ pos = [ctx.find(str(n).lower()) for n in needles]
86
+ pos = [p for p in pos if p >= 0]
87
+ return min(pos) if pos else -1
88
+
89
+
90
+ def _score(q: dict[str, Any], ctx: str) -> dict[str, Any]:
91
+ """``stale`` means the old value leads: it appears and the expected value does not
92
+ appear before it. Mentioning the old value as history after the current one is fine."""
93
+ ctx = ctx.lower()
94
+ found = any(str(e).lower() in ctx for e in q.get("expect_any", []))
95
+ old_at = _first(ctx, q.get("not_expect", []))
96
+ new_at = _first(ctx, q.get("expect_any", []))
97
+ stale = old_at >= 0 and (new_at < 0 or old_at < new_at)
98
+ qtype = str(q.get("type", "fact"))
99
+ ok = (not found) if qtype == "negative" else (found and not stale)
100
+ return {"found": found, "stale": stale, "ok": ok, "context_chars": len(ctx)}
101
+
102
+
103
+ def run(db: str, questions: Path, *, k: int = 8) -> dict[str, Any]:
104
+ rows = [
105
+ json.loads(line)
106
+ for line in questions.read_text(encoding="utf-8").splitlines()
107
+ if line.strip()
108
+ ]
109
+ results: list[dict[str, Any]] = []
110
+ latencies: list[float] = []
111
+ with Store(db) as s:
112
+ for q in rows:
113
+ t0 = time.perf_counter()
114
+ b_ctx, bt_ctx = retrieve(s, q["question"], k)
115
+ latencies.append((time.perf_counter() - t0) * 1000)
116
+ results.append(
117
+ {
118
+ "id": q["id"],
119
+ "type": q.get("type", "fact"),
120
+ "beliefs_injected": bool(b_ctx),
121
+ "beliefs": _score(q, b_ctx),
122
+ "beliefs+turns": _score(q, bt_ctx),
123
+ }
124
+ )
125
+
126
+ def summarize(ctx_key: str) -> dict[str, Any]:
127
+ by_type: dict[str, list[bool]] = {}
128
+ for r in results:
129
+ by_type.setdefault(str(r["type"]), []).append(bool(r[ctx_key]["ok"]))
130
+ n = max(1, len(results))
131
+ chars = [int(r[ctx_key]["context_chars"]) for r in results]
132
+ return {
133
+ "accuracy": sum(bool(r[ctx_key]["ok"]) for r in results) / n,
134
+ "stale_rate": sum(bool(r[ctx_key]["stale"]) for r in results) / n,
135
+ "by_type": {t: sum(v) / len(v) for t, v in by_type.items()},
136
+ "context_chars_median": statistics.median(chars) if chars else 0,
137
+ }
138
+
139
+ n_all = max(1, len(results))
140
+ return {
141
+ "n": len(results),
142
+ "beliefs_injection_rate": sum(bool(r["beliefs_injected"]) for r in results) / n_all,
143
+ "beliefs_injection_rate_negatives": (
144
+ sum(bool(r["beliefs_injected"]) for r in results if r["type"] == "negative")
145
+ / max(1, sum(1 for r in results if r["type"] == "negative"))
146
+ ),
147
+ "beliefs": summarize("beliefs"),
148
+ "beliefs+turns": summarize("beliefs+turns"),
149
+ "latency_ms_median": statistics.median(latencies) if latencies else 0.0,
150
+ "results": results,
151
+ }
152
+
153
+
154
+ def main(argv: list[str] | None = None) -> int:
155
+ p = argparse.ArgumentParser(prog="memware-eval", description=__doc__)
156
+ p.add_argument("questions", type=Path)
157
+ p.add_argument("--db", default=str(DEFAULT_DB))
158
+ p.add_argument("-k", type=int, default=8)
159
+ p.add_argument("--json", action="store_true")
160
+ p.add_argument(
161
+ "--corpus",
162
+ type=Path,
163
+ help="rebuild --db as a clean scratch store from this transcript root first "
164
+ f"(files containing {MARKER!r} are skipped)",
165
+ )
166
+ p.add_argument(
167
+ "--beliefs-from", help="copy the belief ledger from this store into the scratch store"
168
+ )
169
+ p.add_argument("--harness", default="claude-code")
170
+ p.add_argument("--exclude", action="append", default=[], metavar="GLOB")
171
+ p.add_argument(
172
+ "--also-skip",
173
+ action="append",
174
+ default=[],
175
+ metavar="TEXT",
176
+ help="additional content markers that identify evaluation transcripts (repeatable)",
177
+ )
178
+ a = p.parse_args(argv)
179
+ if a.corpus:
180
+ if str(Path(a.db).expanduser()) == str(DEFAULT_DB):
181
+ raise SystemExit(
182
+ "--corpus rebuilds the store: pass a scratch --db, not the default one"
183
+ )
184
+ built = build_clean_store(
185
+ a.db, a.corpus, harness=a.harness, beliefs_from=a.beliefs_from, exclude=a.exclude
186
+ )
187
+ print(f"clean store: {built}", file=sys.stderr)
188
+ rep = run(a.db, a.questions, k=a.k)
189
+ if a.json:
190
+ print(json.dumps(rep, indent=2))
191
+ else:
192
+ for key in ("beliefs", "beliefs+turns"):
193
+ sm = rep[key]
194
+ print(
195
+ f"[{key}] n={rep['n']} accuracy={sm['accuracy']:.3f} "
196
+ f"stale_rate={sm['stale_rate']:.3f} by_type={sm['by_type']} "
197
+ f"context_chars={sm['context_chars_median']:.0f}"
198
+ )
199
+ print(
200
+ f"beliefs injected on {rep['beliefs_injection_rate']:.0%} of questions "
201
+ f"({rep['beliefs_injection_rate_negatives']:.0%} of negatives); "
202
+ f"median_latency={rep['latency_ms_median']:.1f}ms"
203
+ )
204
+ return 0
205
+
206
+
207
+ if __name__ == "__main__":
208
+ raise SystemExit(main())