Brain4Agent 0.0.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.
@@ -0,0 +1,38 @@
1
+ """Brain4Agent -- long-term memory for agents.
2
+
3
+ Chinese-first, single SQLite file, no model in the core path.
4
+
5
+ from brain4agent import MemoryStore
6
+
7
+ with MemoryStore("brain.db") as store:
8
+ sid = store.new_session()
9
+ store.add("中文查询搜不到记忆", session_id=sid, role="user")
10
+ store.add("根因在 keywords.py 第 443 行", session_id=sid, role="assistant")
11
+
12
+ result = store.search("中文查询为什么搜不到")
13
+ print(result.best().missing_terms)
14
+
15
+ The search returns evidence, not a verdict: whether a session answers the
16
+ question is the caller's call. See docs/decisions.md D-011 for why.
17
+ """
18
+
19
+ from brain4agent.evidence import Evidence, JudgedResult, SearchResult, describe
20
+ from brain4agent.judge import Judge, JudgeError
21
+ from brain4agent.store import Memory, MemoryStore
22
+ from brain4agent.tokenize import BM25, tokenize, word_tokens
23
+
24
+ __version__ = "0.0.1"
25
+
26
+ __all__ = [
27
+ "BM25",
28
+ "Evidence",
29
+ "Judge",
30
+ "JudgeError",
31
+ "JudgedResult",
32
+ "Memory",
33
+ "MemoryStore",
34
+ "SearchResult",
35
+ "describe",
36
+ "tokenize",
37
+ "word_tokens",
38
+ ]
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from brain4agent.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
brain4agent/cli.py ADDED
@@ -0,0 +1,402 @@
1
+ """Command line entry point.
2
+
3
+ python -m brain4agent add "中文查询搜不到记忆" --role user
4
+ python -m brain4agent search "中文查询为什么搜不到"
5
+ python -m brain4agent stats
6
+
7
+ The search output deliberately prints ``missing`` when there is one. It is the
8
+ single most useful thing this tool can tell a person: not that it found nothing,
9
+ but which word in the question the corpus never contained.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import contextlib
16
+ import json
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from brain4agent.evidence import describe
22
+ from brain4agent.read import read as read_memory
23
+ from brain4agent.store import MemoryStore
24
+
25
+ DEFAULT_DB = "brain.db"
26
+
27
+
28
+ def default_db() -> Path:
29
+ """``BRAIN4AGENT_DB`` if set, else ``./brain.db``.
30
+
31
+ Relative-by-default on purpose: a memory database that quietly writes to the
32
+ home directory is hard to find and easy to lose.
33
+ """
34
+ env = os.environ.get("BRAIN4AGENT_DB")
35
+ return Path(env) if env else Path(DEFAULT_DB)
36
+
37
+
38
+ def _open(args: argparse.Namespace) -> MemoryStore:
39
+ return MemoryStore(args.db)
40
+
41
+
42
+ def cmd_add(args: argparse.Namespace) -> int:
43
+ store = _open(args)
44
+ try:
45
+ sid = args.session or store.new_session()
46
+ store.add(args.content, session_id=sid, role=args.role, tags=tuple(args.tags))
47
+ print(f"stored in session {sid}")
48
+ return 0
49
+ finally:
50
+ store.close()
51
+
52
+
53
+ def cmd_remember(args: argparse.Namespace) -> int:
54
+ """Store a turn by distilling it into durable memories.
55
+
56
+ Slower than add by design: one model call per turn. The trade is that a
57
+ week of chit-chat does not end up competing with real memories at search
58
+ time. Without an API key this degrades to storing the raw turn.
59
+ """
60
+ store = _open(args)
61
+ try:
62
+ ids = store.remember(
63
+ args.content,
64
+ session_id=args.session,
65
+ role=args.role,
66
+ tags=tuple(args.tags),
67
+ )
68
+ if not ids:
69
+ print("nothing durable in that turn -- not stored")
70
+ return 0
71
+ first = store._by_session_memory(ids[0])
72
+ where = first.session_id if first else args.session or "?"
73
+ print(f"stored {len(ids)} memories in session {where}")
74
+ for mid in ids:
75
+ mem = store._by_session_memory(mid)
76
+ if mem:
77
+ print(f" [{mem.kind} {mem.importance:.1f}] {mem.content[:90]}")
78
+ return 0
79
+ finally:
80
+ store.close()
81
+
82
+
83
+ def cmd_exchange(args: argparse.Namespace) -> int:
84
+ store = _open(args)
85
+ try:
86
+ sid = store.add_exchange(user=args.user, assistant=args.assistant, session_id=args.session)
87
+ print(f"stored exchange in session {sid}")
88
+ return 0
89
+ finally:
90
+ store.close()
91
+
92
+
93
+ def _read_prompt() -> str:
94
+ """Read a prompt from stdin, whatever encoding the shell handed us.
95
+
96
+ Windows is the reason this exists. ``sys.stdin.read()`` decodes with the
97
+ locale codec (cp936 on a Chinese system), so UTF-8 bytes arrive as mojibake
98
+ and a Chinese prompt matches nothing. PowerShell may also hand over UTF-16
99
+ with a BOM when it is piping a string rather than a file. Neither failure is
100
+ loud: the search just returns nothing.
101
+ """
102
+ raw = sys.stdin.buffer.read()
103
+ if not raw.strip():
104
+ return ""
105
+ if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
106
+ return raw.decode("utf-16", errors="replace").strip()
107
+ return raw.decode("utf-8-sig", errors="replace").strip()
108
+
109
+
110
+ def cmd_recall_context(args: argparse.Namespace) -> int:
111
+ """Print memory relevant to a prompt read from stdin, ready to inject.
112
+
113
+ MCP is pull-only: a server cannot volunteer a memory, so an agent sees one
114
+ only when it decides to call ``b4a_recall``. That covers "the user asked
115
+ about something from before" and misses "the task is about to touch
116
+ something we have context on". A client can close that gap with a hook --
117
+ run this before handing the prompt to the model and prepend the output --
118
+ but only if the output is already shaped for injection and silent when
119
+ there is nothing worth saying.
120
+
121
+ Silence is the common case. A hook that emits "no memories found" every turn
122
+ trains the model to skip the block entirely.
123
+ """
124
+ prompt = _read_prompt()
125
+ if not prompt:
126
+ return 0
127
+
128
+ store = _open(args)
129
+ blocks: list[str] = []
130
+ try:
131
+ result = store.search(prompt, top_k=args.top_k, tags=tuple(args.tags) or None)
132
+ if result.empty:
133
+ return 0
134
+
135
+ header = (
136
+ "## Possibly relevant memory\n\n"
137
+ "Excerpts from earlier sessions on this machine, retrieved by keyword\n"
138
+ "match. They may be about a different task. Use them only if they help,\n"
139
+ "and say when you are relying on one.\n"
140
+ )
141
+ used = len(header)
142
+ for e in result.evidence:
143
+ text = store.session_text(e.session_id).strip()
144
+ if not text:
145
+ continue
146
+ block = f"\n[{e.session_id} · coverage {e.coverage:.2f}]\n{text}"
147
+ if used + len(block) > args.max_chars:
148
+ remaining = args.max_chars - used
149
+ if remaining < 200:
150
+ break
151
+ block = block[:remaining] + "\n…"
152
+ blocks.append(block)
153
+ used += len(block)
154
+ if used >= args.max_chars:
155
+ break
156
+ finally:
157
+ store.close()
158
+
159
+ if not blocks:
160
+ return 0
161
+ sys.stdout.write(header + "".join(blocks) + "\n")
162
+ return 0
163
+
164
+
165
+ def cmd_search(args: argparse.Namespace) -> int:
166
+ store = _open(args)
167
+ try:
168
+ result = store.search(args.query, top_k=args.top_k, tags=tuple(args.tags) or None)
169
+ if result.empty:
170
+ print(f"no evidence for {args.query!r}")
171
+ print(f"({result.sessions_searched} sessions searched)")
172
+ if result.corpus_missing:
173
+ # The strongest thing an empty result can say.
174
+ print(f"corpus has no: {', '.join(result.corpus_missing)}")
175
+ return 1
176
+
177
+ print(describe(result))
178
+ for e in result.evidence:
179
+ print()
180
+ print(f"--- {e.session_id} score={e.score:.2f} coverage={e.coverage:.2f} ---")
181
+ if e.missing_terms:
182
+ print(f"this session lacks: {', '.join(e.missing_terms)}")
183
+ print(store.session_text(e.session_id))
184
+ return 0
185
+ finally:
186
+ store.close()
187
+
188
+
189
+ def cmd_import(args: argparse.Namespace) -> int:
190
+ """Bulk-load turns from JSONL, one object per line.
191
+
192
+ Expected shape, all keys optional except content:
193
+
194
+ {"session_id": "s1", "role": "assistant", "content": "..."}
195
+
196
+ A missing session_id starts a new session. Imported as one transaction --
197
+ a thousand individual commits costs more than the thousand writes.
198
+ """
199
+ path = Path(args.path)
200
+ if not path.exists():
201
+ print(f"no such file: {path}", file=sys.stderr)
202
+ return 1
203
+
204
+ store = _open(args)
205
+ try:
206
+ turns = 0
207
+ sessions: set[str] = set()
208
+ # One transaction for the whole file. Any bad line aborts the import and
209
+ # rolls back -- a half-imported history is worse than none, because the
210
+ # caller cannot tell how far it got.
211
+ try:
212
+ with store.batch(), path.open("r", encoding="utf-8-sig") as fh:
213
+ # utf-8-sig, and streamed rather than read whole: hand-written
214
+ # files often carry a BOM, and an import is the one operation
215
+ # likely to be pointed at a large file.
216
+ for lineno, line in enumerate(fh, 1):
217
+ line = line.strip()
218
+ if not line:
219
+ continue
220
+ try:
221
+ obj = json.loads(line)
222
+ except json.JSONDecodeError as exc:
223
+ raise ValueError(f"line {lineno}: {exc}") from exc
224
+ if not isinstance(obj, dict):
225
+ raise ValueError(
226
+ f"line {lineno}: expected a JSON object, got {type(obj).__name__}"
227
+ )
228
+ content = obj.get("content")
229
+ if not isinstance(content, str):
230
+ raise ValueError(f"line {lineno}: 'content' must be a string")
231
+ content = content.strip()
232
+ if not content:
233
+ continue
234
+ sid = obj.get("session_id") or store.new_session()
235
+ store.add(content, session_id=sid, role=obj.get("role") or "user")
236
+ sessions.add(sid)
237
+ turns += 1
238
+ except ValueError as exc:
239
+ print(f"import aborted, nothing written: {exc}", file=sys.stderr)
240
+ return 1
241
+ print(f"imported {turns} turns across {len(sessions)} sessions")
242
+ return 0
243
+ finally:
244
+ store.close()
245
+
246
+
247
+ def cmd_ask(args: argparse.Namespace) -> int:
248
+ """Retrieve then read: an answer instead of a pile of sessions."""
249
+ store = _open(args)
250
+ try:
251
+ result = read_memory(store, args.query, top_k=args.top_k)
252
+ if result.answer is None:
253
+ print(
254
+ "no model configured (set BRAIN4AGENT_LLM_KEY); use 'search' to get the evidence",
255
+ file=sys.stderr,
256
+ )
257
+ return 1
258
+ print(result.answer)
259
+ return 0
260
+ finally:
261
+ store.close()
262
+
263
+
264
+ def cmd_sessions(args: argparse.Namespace) -> int:
265
+ store = _open(args)
266
+ try:
267
+ if not store._by_session:
268
+ print("(no sessions)")
269
+ return 0
270
+ for sid in store._by_session:
271
+ turns = store.session(sid)
272
+ first = turns[0].content[:40].replace("\n", " ")
273
+ print(f"{sid} {len(turns):2d} turns {first}")
274
+ return 0
275
+ finally:
276
+ store.close()
277
+
278
+
279
+ def cmd_stats(args: argparse.Namespace) -> int:
280
+ store = _open(args)
281
+ try:
282
+ st = store.stats()
283
+ print(f"turns {st['turns']}")
284
+ print(f"sessions {st['sessions']}")
285
+ print(f"terms {st['index_terms']}")
286
+ print(f"db {args.db}")
287
+ return 0
288
+ finally:
289
+ store.close()
290
+
291
+
292
+ def cmd_prune(args: argparse.Namespace) -> int:
293
+ """Keep only the most recently written sessions."""
294
+ store = _open(args)
295
+ try:
296
+ before = store.stats()["sessions"]
297
+ removed = store.prune(keep_sessions=args.keep)
298
+ print(f"removed {removed} sessions, {before} -> {store.stats()['sessions']}")
299
+ return 0
300
+ finally:
301
+ store.close()
302
+
303
+
304
+ def cmd_forget(args: argparse.Namespace) -> int:
305
+ store = _open(args)
306
+ try:
307
+ removed = store.forget_session(args.session)
308
+ print(f"removed {removed} turns from {args.session}")
309
+ return 0 if removed else 1
310
+ finally:
311
+ store.close()
312
+
313
+
314
+ def build_parser() -> argparse.ArgumentParser:
315
+ p = argparse.ArgumentParser(
316
+ prog="brain4agent",
317
+ description="Long-term memory for agents. Chinese-first, no model in the core path.",
318
+ )
319
+ p.add_argument("--db", type=Path, default=default_db(), help="database path")
320
+ sub = p.add_subparsers(dest="command", required=True)
321
+
322
+ a = sub.add_parser("add", help="store one turn")
323
+ a.add_argument("content")
324
+ a.add_argument("--session", "-s", default=None)
325
+ a.add_argument("--role", default="user", choices=["user", "assistant", "system"])
326
+ a.add_argument("--tag", action="append", dest="tags", default=[], help="label; repeatable")
327
+ a.set_defaults(func=cmd_add)
328
+
329
+ r = sub.add_parser("remember", help="distil a turn into durable memories")
330
+ r.add_argument("content")
331
+ r.add_argument("--session", "-s", default=None)
332
+ r.add_argument("--role", default="user", choices=["user", "assistant", "system"])
333
+ r.add_argument("--tag", action="append", dest="tags", default=[], help="label; repeatable")
334
+ r.set_defaults(func=cmd_remember)
335
+
336
+ e = sub.add_parser("exchange", help="store a user/assistant pair as one session")
337
+ e.add_argument("user")
338
+ e.add_argument("assistant")
339
+ e.add_argument("--session", "-s", default=None)
340
+ e.set_defaults(func=cmd_exchange)
341
+
342
+ s = sub.add_parser("search", help="search and show evidence")
343
+ s.add_argument("query")
344
+ s.add_argument("--top-k", "-k", type=int, default=3)
345
+ s.add_argument("--tag", action="append", dest="tags", default=[], help="filter; repeatable")
346
+ s.set_defaults(func=cmd_search)
347
+
348
+ rc = sub.add_parser(
349
+ "recall-context", help="print memory relevant to a prompt on stdin (for hooks)"
350
+ )
351
+ rc.add_argument("--top-k", "-k", type=int, default=3)
352
+ rc.add_argument("--max-chars", type=int, default=1500)
353
+ rc.add_argument("--tag", action="append", dest="tags", default=[])
354
+ rc.set_defaults(func=cmd_recall_context)
355
+
356
+ q = sub.add_parser("ask", help="retrieve and answer in one step")
357
+ q.add_argument("query")
358
+ q.add_argument("--top-k", "-k", type=int, default=5)
359
+ q.set_defaults(func=cmd_ask)
360
+
361
+ i = sub.add_parser("import", help="bulk-load turns from JSONL")
362
+ i.add_argument("path")
363
+ i.set_defaults(func=cmd_import)
364
+
365
+ sub.add_parser("sessions", help="list sessions").set_defaults(func=cmd_sessions)
366
+ sub.add_parser("stats", help="show counts").set_defaults(func=cmd_stats)
367
+
368
+ pr = sub.add_parser("prune", help="keep only the newest N sessions")
369
+ pr.add_argument("--keep", type=int, required=True)
370
+ pr.set_defaults(func=cmd_prune)
371
+
372
+ f = sub.add_parser("forget", help="delete a session")
373
+ f.add_argument("session")
374
+ f.set_defaults(func=cmd_forget)
375
+
376
+ return p
377
+
378
+
379
+ def _force_utf8_output() -> None:
380
+ """Write UTF-8 to stdout/stderr, and never crash on an unencodable character.
381
+
382
+ On a Chinese Windows console the default encoding is cp936, so printing a
383
+ memory containing an emoji raised UnicodeEncodeError and exited non-zero
384
+ after the work had already succeeded. errors="replace" keeps the exit code
385
+ honest at the cost of a garbled glyph.
386
+
387
+ Note this cannot fix argv: Python decoded the command line with the locale
388
+ encoding before this ran. Set PYTHONUTF8=1 on Windows for full support.
389
+ """
390
+ for stream in (sys.stdout, sys.stderr):
391
+ with contextlib.suppress(AttributeError, ValueError): # exotic streams
392
+ stream.reconfigure(encoding="utf-8", errors="replace")
393
+
394
+
395
+ def main(argv: list[str] | None = None) -> int:
396
+ _force_utf8_output()
397
+ args = build_parser().parse_args(argv)
398
+ return int(args.func(args))
399
+
400
+
401
+ if __name__ == "__main__":
402
+ sys.exit(main())