commonmemo 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.
- commonmemo/__init__.py +171 -0
- commonmemo/__main__.py +7 -0
- commonmemo/agent_tools.py +240 -0
- commonmemo/cli.py +158 -0
- commonmemo/context.py +284 -0
- commonmemo/dates.py +138 -0
- commonmemo/engines/__init__.py +44 -0
- commonmemo/engines/_ranking.py +44 -0
- commonmemo/engines/base.py +128 -0
- commonmemo/engines/graphiti.py +337 -0
- commonmemo/engines/local.py +316 -0
- commonmemo/engines/mem0.py +288 -0
- commonmemo/environment.py +348 -0
- commonmemo/events.py +543 -0
- commonmemo/extraction.py +221 -0
- commonmemo/facts.py +219 -0
- commonmemo/history.py +366 -0
- commonmemo/hookclient.py +114 -0
- commonmemo/hooks.py +292 -0
- commonmemo/hookserver.py +166 -0
- commonmemo/inspector.py +228 -0
- commonmemo/inspector_ui.py +487 -0
- commonmemo/integrations/__init__.py +2 -0
- commonmemo/integrations/commonadk.py +245 -0
- commonmemo/knowledge.py +1216 -0
- commonmemo/learning.py +333 -0
- commonmemo/mcp.py +285 -0
- commonmemo/memory.py +985 -0
- commonmemo/opencode_plugin.js +96 -0
- commonmemo/otel.py +119 -0
- commonmemo/procedures.py +330 -0
- commonmemo/projections.py +480 -0
- commonmemo/ranking.py +144 -0
- commonmemo/reins.py +342 -0
- commonmemo/resume.py +286 -0
- commonmemo/runs.py +49 -0
- commonmemo/said.py +251 -0
- commonmemo/session.py +541 -0
- commonmemo/sqldb.py +228 -0
- commonmemo/stores/__init__.py +29 -0
- commonmemo/stores/base.py +189 -0
- commonmemo/stores/memory.py +199 -0
- commonmemo/stores/sql.py +423 -0
- commonmemo/stores/sqlite.py +7 -0
- commonmemo/tool_memory.py +166 -0
- commonmemo/traces.py +160 -0
- commonmemo/views.py +218 -0
- commonmemo-0.0.1.dist-info/METADATA +840 -0
- commonmemo-0.0.1.dist-info/RECORD +52 -0
- commonmemo-0.0.1.dist-info/WHEEL +4 -0
- commonmemo-0.0.1.dist-info/entry_points.txt +2 -0
- commonmemo-0.0.1.dist-info/licenses/LICENSE +201 -0
commonmemo/__init__.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""CommonMemo -- framework-neutral memory for agents and workflows.
|
|
2
|
+
|
|
3
|
+
Four tiers behind one `Memory` handle, plus structured kinds (tool memory,
|
|
4
|
+
world facts, feedback, negatives, resume points) kept beside them:
|
|
5
|
+
|
|
6
|
+
- **Log** -- everything that happened, in order, never edited (owned by CommonMemo)
|
|
7
|
+
- **Working** -- what the current conversation or run has in hand (owned by CommonMemo)
|
|
8
|
+
- **Recaps** -- short summaries of finished sessions (delegated to an engine)
|
|
9
|
+
- **UserProfile** -- durable facts about one user (delegated to an engine)
|
|
10
|
+
|
|
11
|
+
See docs/design.md for the full design.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
TYPE_CHECKING = False # not `from typing import TYPE_CHECKING`: importing typing costs 7 ms a hook (#92)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .context import MemoryContext, MemoryPolicy
|
|
20
|
+
from .engines import Engine, Fact, LocalEngine, Recap
|
|
21
|
+
from .events import (
|
|
22
|
+
Checkpoint,
|
|
23
|
+
ExternalEvent,
|
|
24
|
+
Feedback,
|
|
25
|
+
Forgotten,
|
|
26
|
+
MemoryEvent,
|
|
27
|
+
Message,
|
|
28
|
+
Negative,
|
|
29
|
+
ProcedureEntry,
|
|
30
|
+
ProcedureFollowed,
|
|
31
|
+
ToolInvoked,
|
|
32
|
+
UserFact,
|
|
33
|
+
WorldFact,
|
|
34
|
+
event_from_dict,
|
|
35
|
+
)
|
|
36
|
+
from .environment import EnvironmentResult
|
|
37
|
+
from .facts import FactKey
|
|
38
|
+
from .ranking import EmbeddingRanker, KeywordRanker
|
|
39
|
+
from .history import Diff, History, MemoryView
|
|
40
|
+
from .extraction import ExtractedFact, Extractor, ModelExtractor
|
|
41
|
+
from .learning import Learner, ModelLearner
|
|
42
|
+
from .memory import Memory
|
|
43
|
+
from .procedures import TaskProcedures
|
|
44
|
+
from .reins import Alert, AlertPolicy, Budget, RevertResult
|
|
45
|
+
from .resume import ResumePoint, StepInDoubt, StepInProgress
|
|
46
|
+
from .runs import Run, current_run
|
|
47
|
+
from .session import Session, current_session
|
|
48
|
+
from .stores import SQLiteStore, Store
|
|
49
|
+
from .tool_memory import ToolProfile
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Importing the package must stay cheap: a hook process imports `commonmemo.hookclient` and nothing
|
|
53
|
+
# else (#92), so the public names load on first use (PEP 562).
|
|
54
|
+
_EXPORTS = {
|
|
55
|
+
"MemoryContext": ".context",
|
|
56
|
+
"MemoryPolicy": ".context",
|
|
57
|
+
"Engine": ".engines",
|
|
58
|
+
"Fact": ".engines",
|
|
59
|
+
"LocalEngine": ".engines",
|
|
60
|
+
"Recap": ".engines",
|
|
61
|
+
"Checkpoint": ".events",
|
|
62
|
+
"ExternalEvent": ".events",
|
|
63
|
+
"Feedback": ".events",
|
|
64
|
+
"Forgotten": ".events",
|
|
65
|
+
"MemoryEvent": ".events",
|
|
66
|
+
"Message": ".events",
|
|
67
|
+
"Negative": ".events",
|
|
68
|
+
"ProcedureEntry": ".events",
|
|
69
|
+
"ProcedureFollowed": ".events",
|
|
70
|
+
"ToolInvoked": ".events",
|
|
71
|
+
"UserFact": ".events",
|
|
72
|
+
"WorldFact": ".events",
|
|
73
|
+
"event_from_dict": ".events",
|
|
74
|
+
"EnvironmentResult": ".environment",
|
|
75
|
+
"FactKey": ".facts",
|
|
76
|
+
"EmbeddingRanker": ".ranking",
|
|
77
|
+
"KeywordRanker": ".ranking",
|
|
78
|
+
"Diff": ".history",
|
|
79
|
+
"History": ".history",
|
|
80
|
+
"MemoryView": ".history",
|
|
81
|
+
"ExtractedFact": ".extraction",
|
|
82
|
+
"Extractor": ".extraction",
|
|
83
|
+
"ModelExtractor": ".extraction",
|
|
84
|
+
"Learner": ".learning",
|
|
85
|
+
"ModelLearner": ".learning",
|
|
86
|
+
"Memory": ".memory",
|
|
87
|
+
"TaskProcedures": ".procedures",
|
|
88
|
+
"Alert": ".reins",
|
|
89
|
+
"AlertPolicy": ".reins",
|
|
90
|
+
"Budget": ".reins",
|
|
91
|
+
"RevertResult": ".reins",
|
|
92
|
+
"ResumePoint": ".resume",
|
|
93
|
+
"StepInDoubt": ".resume",
|
|
94
|
+
"StepInProgress": ".resume",
|
|
95
|
+
"Run": ".runs",
|
|
96
|
+
"current_run": ".runs",
|
|
97
|
+
"Session": ".session",
|
|
98
|
+
"current_session": ".session",
|
|
99
|
+
"SQLiteStore": ".stores",
|
|
100
|
+
"Store": ".stores",
|
|
101
|
+
"ToolProfile": ".tool_memory",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def __getattr__(name: str):
|
|
106
|
+
module = _EXPORTS.get(name)
|
|
107
|
+
if module is None:
|
|
108
|
+
raise AttributeError(f"module 'commonmemo' has no attribute {name!r}")
|
|
109
|
+
from importlib import import_module
|
|
110
|
+
|
|
111
|
+
value = getattr(import_module(module, __name__), name)
|
|
112
|
+
globals()[name] = value
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def __dir__() -> list[str]:
|
|
117
|
+
return sorted(set(globals()) | set(_EXPORTS))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__version__ = "0.0.1"
|
|
121
|
+
|
|
122
|
+
__all__ = [
|
|
123
|
+
"Memory",
|
|
124
|
+
"Session",
|
|
125
|
+
"current_session",
|
|
126
|
+
"MemoryContext",
|
|
127
|
+
"MemoryPolicy",
|
|
128
|
+
"EnvironmentResult",
|
|
129
|
+
"FactKey",
|
|
130
|
+
"EmbeddingRanker",
|
|
131
|
+
"KeywordRanker",
|
|
132
|
+
"WorldFact",
|
|
133
|
+
"Feedback",
|
|
134
|
+
"Negative",
|
|
135
|
+
"Forgotten",
|
|
136
|
+
"Checkpoint",
|
|
137
|
+
"ProcedureEntry",
|
|
138
|
+
"ProcedureFollowed",
|
|
139
|
+
"TaskProcedures",
|
|
140
|
+
"ExtractedFact",
|
|
141
|
+
"Extractor",
|
|
142
|
+
"Learner",
|
|
143
|
+
"ModelExtractor",
|
|
144
|
+
"ModelLearner",
|
|
145
|
+
"ResumePoint",
|
|
146
|
+
"StepInDoubt",
|
|
147
|
+
"StepInProgress",
|
|
148
|
+
"ToolProfile",
|
|
149
|
+
"History",
|
|
150
|
+
"MemoryView",
|
|
151
|
+
"Diff",
|
|
152
|
+
"Run",
|
|
153
|
+
"Alert",
|
|
154
|
+
"AlertPolicy",
|
|
155
|
+
"Budget",
|
|
156
|
+
"RevertResult",
|
|
157
|
+
"current_run",
|
|
158
|
+
"MemoryEvent",
|
|
159
|
+
"Message",
|
|
160
|
+
"ToolInvoked",
|
|
161
|
+
"UserFact",
|
|
162
|
+
"ExternalEvent",
|
|
163
|
+
"event_from_dict",
|
|
164
|
+
"Engine",
|
|
165
|
+
"Recap",
|
|
166
|
+
"Fact",
|
|
167
|
+
"LocalEngine",
|
|
168
|
+
"Store",
|
|
169
|
+
"SQLiteStore",
|
|
170
|
+
"__version__",
|
|
171
|
+
]
|
commonmemo/__main__.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Memory tools for agents -- the *pull* half of hybrid memory.
|
|
2
|
+
|
|
3
|
+
`Memory.agent_tools(session)` returns plain, typed, documented functions
|
|
4
|
+
bound to one session, so any framework that turns Python functions into
|
|
5
|
+
tools can use them as they are (CommonADK requires exactly that shape).
|
|
6
|
+
|
|
7
|
+
Reads go further than the push: `memory_recall` searches every kind with no
|
|
8
|
+
relevance threshold. Writes are recorded with `source="agent"`, so what an
|
|
9
|
+
agent wrote can always be told apart from what the user or the application
|
|
10
|
+
stated -- and reviewed, or revoked.
|
|
11
|
+
|
|
12
|
+
Every name starts with `memory_`, so the tools cannot collide with an
|
|
13
|
+
agent's own.
|
|
14
|
+
|
|
15
|
+
The tools are **synchronous**: not every framework awaits an async tool
|
|
16
|
+
(CommonADK's Claude adapter calls tool functions directly). Engine search
|
|
17
|
+
is async, so `memory_recall` runs it on a worker thread with its own event
|
|
18
|
+
loop; an engine whose client is bound to the caller's loop (Graphiti's
|
|
19
|
+
async Neo4j driver can be) falls back to the exact local index rather than
|
|
20
|
+
failing the tool call.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import concurrent.futures
|
|
27
|
+
import json
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
|
29
|
+
|
|
30
|
+
from .context import MemoryContext, world_line
|
|
31
|
+
from .events import Checkpoint, Negative, UserFact
|
|
32
|
+
from .ranking import rank
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from .session import Session
|
|
36
|
+
|
|
37
|
+
def run_sync(coro: Coroutine[Any, Any, Any]) -> Any:
|
|
38
|
+
"""Run `coro` to completion from synchronous code, inside or outside a
|
|
39
|
+
running event loop."""
|
|
40
|
+
try:
|
|
41
|
+
asyncio.get_running_loop()
|
|
42
|
+
except RuntimeError:
|
|
43
|
+
return asyncio.run(coro)
|
|
44
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
45
|
+
return pool.submit(asyncio.run, coro).result()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_agent_tools(session: "Session", *, writes: bool = True) -> list[Callable[..., str]]:
|
|
49
|
+
"""The memory tools for `session`. `writes=False` gives read-only
|
|
50
|
+
tools (recall only). The session then knows its agent can recall, so
|
|
51
|
+
its push says when it left things out."""
|
|
52
|
+
memory = session.memory
|
|
53
|
+
session.recall_tool = True
|
|
54
|
+
|
|
55
|
+
def memory_recall(query: str) -> str:
|
|
56
|
+
"""Search long-term memory: past sessions, what was said in them,
|
|
57
|
+
facts about the user, known world facts, past feedback, lessons and
|
|
58
|
+
decisions. Use it when the request may
|
|
59
|
+
depend on something from an earlier conversation that is not in
|
|
60
|
+
front of you. Returns matching entries, best first, or a note that
|
|
61
|
+
nothing matched.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
query: What to look for, in plain words.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
ctx = run_sync(session.context(query, policy=memory.policy_for_recall))
|
|
68
|
+
except Exception: # noqa: BLE001 -- see module docstring: degrade to the local index
|
|
69
|
+
ctx = run_sync(session.context(query, policy=memory.policy_for_recall, local_only=True))
|
|
70
|
+
bar = memory.policy_for_recall.min_recall_score
|
|
71
|
+
|
|
72
|
+
def related(items: list, text: Callable[[Any], str]) -> list:
|
|
73
|
+
# Best first, by the memory's ranker: by meaning with an embedder (#21).
|
|
74
|
+
return run_sync(rank(memory.ranker, query, items, text, threshold=bar))[0]
|
|
75
|
+
|
|
76
|
+
found = MemoryContext(
|
|
77
|
+
facts=related(ctx.facts, lambda f: f"{f.key} {f.value}"),
|
|
78
|
+
world=related([*ctx.world, *ctx.world_turn], memory._world_text),
|
|
79
|
+
lessons=related(ctx.lessons, lambda item: item.line()),
|
|
80
|
+
learned=related(ctx.learned, lambda x: x.text),
|
|
81
|
+
recaps=ctx.recaps, said=ctx.said, policy=memory.policy, agent=session.agent,
|
|
82
|
+
)
|
|
83
|
+
decisions = (related(memory.decisions(user_id=session.user_id)[:50],
|
|
84
|
+
lambda d: f"{d.goal} {d.chosen} {d.outcome}")[:5] if session.user_id is not None else [])
|
|
85
|
+
lines = []
|
|
86
|
+
lines += [f"[user] {f.key}: {f.value}" for f in found.facts]
|
|
87
|
+
lines += [f"[fact] {world_line(w)}" for w in found.world]
|
|
88
|
+
lines += [f"[feedback] {item.line()}" for item in found.lessons]
|
|
89
|
+
lines += [f"[lesson] {x.line()}" for x in found.learned]
|
|
90
|
+
lines += [f"[decision] {d.line()}" for d in decisions]
|
|
91
|
+
lines += [f"[past session] {ctx.recap_line(e)}" for e in found.recaps]
|
|
92
|
+
lines += [f"[said] {w.line()}" for w in found.said]
|
|
93
|
+
text = "\n".join(lines) if lines else "Nothing in memory matches that."
|
|
94
|
+
if writes and not session.closed: # what the agent pulled, as a trace (#50); read-only tools record nothing
|
|
95
|
+
session._trace("recall", found, text, extra=tuple(
|
|
96
|
+
{"kind": "decision", "id": d.event_id, "source": "learned", "events": [d.event_id]} for d in decisions
|
|
97
|
+
), query=query)
|
|
98
|
+
return text
|
|
99
|
+
|
|
100
|
+
def memory_get_procedure(task: str) -> str:
|
|
101
|
+
"""How a type of task is done here: its templates (the shape of a good
|
|
102
|
+
result), rules, step-by-step procedures, and accepted examples. Use it
|
|
103
|
+
before doing one of the tasks listed below, or when memory pointed you
|
|
104
|
+
at it.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
task: The task's name, as listed.
|
|
108
|
+
"""
|
|
109
|
+
known = memory.tasks(user_id=session.user_id)
|
|
110
|
+
found = memory.procedures(task, user_id=session.user_id) if task else None
|
|
111
|
+
if not found:
|
|
112
|
+
listing = "\n".join(f"- {t}: {w}" if w else f"- {t}" for t, w in known.items())
|
|
113
|
+
head = f"No procedure for {task!r}. " if task else ""
|
|
114
|
+
return head + ("Known tasks:\n" + listing if known else "No procedures are recorded yet.")
|
|
115
|
+
found.how = "pulled"
|
|
116
|
+
text = found.full_text()
|
|
117
|
+
if writes and not session.closed: # read-only tools record nothing
|
|
118
|
+
session._trace("pull", MemoryContext(procedure=found, policy=memory.policy, agent=session.agent), text)
|
|
119
|
+
return text
|
|
120
|
+
|
|
121
|
+
reads: list[Callable[..., str]] = [memory_recall]
|
|
122
|
+
tasks = memory.tasks(user_id=session.user_id)
|
|
123
|
+
if tasks:
|
|
124
|
+
# Only when there is something to get; the task names go where the
|
|
125
|
+
# model reads them, the tool's description.
|
|
126
|
+
head, _, args = (memory_get_procedure.__doc__ or "").partition("\n\n Args:")
|
|
127
|
+
listing = "\n".join(f" - {t}: {w}" if w else f" - {t}" for t, w in list(tasks.items())[:40])
|
|
128
|
+
memory_get_procedure.__doc__ = f"{head}\n\n Known tasks:\n{listing}\n\n Args:{args}"
|
|
129
|
+
reads.append(memory_get_procedure)
|
|
130
|
+
|
|
131
|
+
if not writes:
|
|
132
|
+
return reads
|
|
133
|
+
|
|
134
|
+
def memory_remember(key: str, value: str) -> str:
|
|
135
|
+
"""Save a durable fact about the current user for future sessions,
|
|
136
|
+
e.g. key "preferred_contact", value "email". Use it only for things
|
|
137
|
+
the user stated or clearly confirmed. A new value for the same key
|
|
138
|
+
replaces the old one.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
key: Short snake_case name of the fact.
|
|
142
|
+
value: The fact itself.
|
|
143
|
+
"""
|
|
144
|
+
if session.user_id is None:
|
|
145
|
+
return "Not saved: this session has no user to remember things about."
|
|
146
|
+
session.record(UserFact(key=key, value=value, source="agent"))
|
|
147
|
+
return f"Saved: {key} = {value}"
|
|
148
|
+
|
|
149
|
+
def memory_note_fact(key: str, value: str, valid_for_seconds: int = 0) -> str:
|
|
150
|
+
"""Note a fact about the world or this project (not about the user)
|
|
151
|
+
for future sessions, e.g. key "refund_window", value "30 days".
|
|
152
|
+
Facts you note are proposals: only this user sees them until an
|
|
153
|
+
operator approves them, because a world fact is shown to every user.
|
|
154
|
+
Set valid_for_seconds for facts that go stale (prices, statuses);
|
|
155
|
+
0 means it does not expire.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
key: Short snake_case name of the fact.
|
|
159
|
+
value: The fact itself.
|
|
160
|
+
valid_for_seconds: How long the fact stays true; 0 for no expiry.
|
|
161
|
+
"""
|
|
162
|
+
event = session.learn(key, value, ttl=valid_for_seconds or None, source="agent")
|
|
163
|
+
if event.proposed:
|
|
164
|
+
why = f" Note: {event.note}." if event.note else ""
|
|
165
|
+
return f"Proposed fact (other users see it once an operator approves it): {key} = {value}.{why}"
|
|
166
|
+
return f"Saved fact: {key} = {value}"
|
|
167
|
+
|
|
168
|
+
if memory.fact_keys:
|
|
169
|
+
# List the registered keys where the model reads them: the tool's own
|
|
170
|
+
# description, so it reuses `refund_window` instead of inventing
|
|
171
|
+
# `refund_period`.
|
|
172
|
+
head, _, args = (memory_note_fact.__doc__ or "").partition("\n\n Args:")
|
|
173
|
+
known = "\n".join(f" - {line}" for line in memory.fact_keys.agent_lines())
|
|
174
|
+
memory_note_fact.__doc__ = f"{head}\n\n Use one of these keys when it fits:\n{known}\n\n Args:{args}"
|
|
175
|
+
|
|
176
|
+
def memory_avoid(statement: str, reason: str = "") -> str:
|
|
177
|
+
"""Record something to never do again for this user (or, without a
|
|
178
|
+
user, for this agent), e.g. "suggest phone calls". Use it when the
|
|
179
|
+
user asks you to stop doing something.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
statement: What to avoid, phrased as an action.
|
|
183
|
+
reason: Why, if the user said.
|
|
184
|
+
"""
|
|
185
|
+
scope = f"user:{session.user_id}" if session.user_id else f"agent:{session.agent or 'default'}"
|
|
186
|
+
session.record(Negative(statement=statement, scope=scope, reason=reason or None, source="agent"))
|
|
187
|
+
return f"Will avoid: {statement}"
|
|
188
|
+
|
|
189
|
+
def memory_save_progress(note: str, data_json: str = "{}") -> str:
|
|
190
|
+
"""Record progress on a long task so it can be resumed if this
|
|
191
|
+
session is interrupted: what is done and what comes next.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
note: One line on where the work stands.
|
|
195
|
+
data_json: Optional JSON object with details needed to continue.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
data = json.loads(data_json) if data_json.strip() else {}
|
|
199
|
+
except ValueError:
|
|
200
|
+
data = {"raw": data_json}
|
|
201
|
+
if not isinstance(data, dict):
|
|
202
|
+
data = {"value": data}
|
|
203
|
+
session.record(Checkpoint(label=note, data=data))
|
|
204
|
+
return "Progress saved."
|
|
205
|
+
|
|
206
|
+
def memory_save_procedure(task: str, kind: str, name: str, body: str, use_when: str = "",
|
|
207
|
+
for_everyone: bool = False) -> str:
|
|
208
|
+
"""Save how a type of task is done, for next time: a "template" (the
|
|
209
|
+
shape of a good result), a "rule" it must follow, a "procedure" (its
|
|
210
|
+
steps, one numbered line each), or an "exemplar" (a result the user
|
|
211
|
+
accepted). By default it is this user's own way; for_everyone=True
|
|
212
|
+
proposes it for every user, and an operator approves it first.
|
|
213
|
+
Exemplars always stay with this user.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
task: Short snake_case name of the task, e.g. "weekly_report".
|
|
217
|
+
kind: "template", "rule", "procedure" or "exemplar".
|
|
218
|
+
name: Short name of this entry, e.g. "default".
|
|
219
|
+
body: The template, rule, steps or example itself.
|
|
220
|
+
use_when: One line saying when it applies.
|
|
221
|
+
for_everyone: Propose it for every user instead of keeping it for this one.
|
|
222
|
+
"""
|
|
223
|
+
if kind not in ("template", "rule", "procedure", "exemplar"):
|
|
224
|
+
return f"Not saved: kind must be template, rule, procedure or exemplar, not {kind!r}."
|
|
225
|
+
if kind == "exemplar":
|
|
226
|
+
if session.user_id is None:
|
|
227
|
+
return "Not saved: an exemplar belongs to a user, and this session has none."
|
|
228
|
+
event = session.save_exemplar(task, body, name=name, use_when=use_when, source="agent")
|
|
229
|
+
else:
|
|
230
|
+
# Without a user there is no "own way": it can only be a proposal.
|
|
231
|
+
mine = not for_everyone and session.user_id is not None
|
|
232
|
+
event = session.save_procedure(task, kind, name, body, use_when=use_when, for_user=mine, source="agent")
|
|
233
|
+
if event.proposed:
|
|
234
|
+
return f"Proposed {kind} {name!r} for {task} (other users get it once an operator approves it)."
|
|
235
|
+
return f"Saved {kind} {name!r} for {task} (this user's own)."
|
|
236
|
+
|
|
237
|
+
return [*reads, memory_remember, memory_note_fact, memory_avoid, memory_save_progress, memory_save_procedure]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
__all__ = ["build_agent_tools"]
|
commonmemo/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The `commonmemo` command. Subcommands:
|
|
2
|
+
|
|
3
|
+
commonmemo mcp [options] # memory as an MCP server over stdio (mcp.py)
|
|
4
|
+
commonmemo hook --harness NAME [options] # one harness hook event, JSON on stdin (hooks.py)
|
|
5
|
+
commonmemo setup NAME [options] # print the hook config for a harness (hooks.py)
|
|
6
|
+
commonmemo show user ID | session ID # what memory holds, read-only (views.py)
|
|
7
|
+
commonmemo trace SESSION [--why KEY] # what each push gave the agent, and why (traces.py)
|
|
8
|
+
commonmemo inspect [--port N] # the same, in a local read-only web page (inspector.py)
|
|
9
|
+
|
|
10
|
+
They share the memory options below: a flag wins over its `COMMONMEMO_*`
|
|
11
|
+
environment variable, which wins over the default.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import TYPE_CHECKING, Optional
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import argparse
|
|
22
|
+
|
|
23
|
+
from .context import MemoryPolicy
|
|
24
|
+
from .memory import Memory
|
|
25
|
+
|
|
26
|
+
DEFAULT_STORE = "sqlite:///.commonmemo/memory.db"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def env_flag(name: str) -> bool:
|
|
30
|
+
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
WINDOWS = "8"
|
|
34
|
+
"""`MemoryPolicy.messages` and `recall_messages`, as the option default (a test keeps them equal)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def memory_options(p: argparse.ArgumentParser, *, agent: Optional[str]) -> argparse.ArgumentParser:
|
|
38
|
+
"""Add the options that pick the memory, user, agent and session."""
|
|
39
|
+
env = os.environ.get
|
|
40
|
+
p.add_argument("--store", default=env("COMMONMEMO_STORE", DEFAULT_STORE))
|
|
41
|
+
p.add_argument("--engine", default=env("COMMONMEMO_ENGINE", "local"))
|
|
42
|
+
p.add_argument("--project", default=env("COMMONMEMO_PROJECT"))
|
|
43
|
+
p.add_argument("--user", default=env("COMMONMEMO_USER"))
|
|
44
|
+
p.add_argument("--agent", default=env("COMMONMEMO_AGENT", agent))
|
|
45
|
+
p.add_argument("--session", default=env("COMMONMEMO_SESSION"))
|
|
46
|
+
p.add_argument("--read-only", action="store_true", default=env_flag("COMMONMEMO_READ_ONLY"))
|
|
47
|
+
p.add_argument("--messages", type=int, default=int(env("COMMONMEMO_MESSAGES", WINDOWS)),
|
|
48
|
+
help="windows of the user's past messages to push with each turn (default 8; 0: none)")
|
|
49
|
+
p.add_argument("--recall-messages", type=int,
|
|
50
|
+
default=int(env("COMMONMEMO_RECALL_MESSAGES", WINDOWS)),
|
|
51
|
+
help="windows of past messages memory_recall returns (default 8; 0: none)")
|
|
52
|
+
p.add_argument("--message-dates", choices=("off", "stamp", "resolve"), default=env("COMMONMEMO_MESSAGE_DATES", "off"),
|
|
53
|
+
help="what windows say about when: off, stamp (dated messages), resolve (also relative dates)")
|
|
54
|
+
return p
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def policy_from(args: argparse.Namespace) -> "MemoryPolicy":
|
|
58
|
+
"""The memory policy the command line asks for."""
|
|
59
|
+
from .context import MemoryPolicy
|
|
60
|
+
|
|
61
|
+
return MemoryPolicy(messages=args.messages, recall_messages=args.recall_messages, message_dates=args.message_dates)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
65
|
+
args_ = sys.argv[1:] if argv is None else argv
|
|
66
|
+
if args_[:1] == ["hook"] and ("--resident" in args_ or env_flag("COMMONMEMO_RESIDENT")):
|
|
67
|
+
from .hookclient import main as client_main # imports almost nothing: that is the point (#92)
|
|
68
|
+
|
|
69
|
+
return client_main(args_[1:])
|
|
70
|
+
import argparse
|
|
71
|
+
|
|
72
|
+
parser = argparse.ArgumentParser(prog="commonmemo")
|
|
73
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
74
|
+
sub.add_parser("mcp", help="serve memory to MCP clients over stdio", add_help=False)
|
|
75
|
+
sub.add_parser("hook", help="handle one harness hook event (JSON on stdin)", add_help=False)
|
|
76
|
+
sub.add_parser("setup", help="print the hook config for claude-code, codex or opencode", add_help=False)
|
|
77
|
+
sub.add_parser("show", help="show a user or a session, read-only", add_help=False)
|
|
78
|
+
sub.add_parser("trace", help="show a session's turn traces", add_help=False)
|
|
79
|
+
sub.add_parser("inspect", help="open a local read-only web inspector", add_help=False)
|
|
80
|
+
args, rest = parser.parse_known_args(argv)
|
|
81
|
+
if args.command == "mcp":
|
|
82
|
+
from .mcp import main as mcp_main
|
|
83
|
+
|
|
84
|
+
return mcp_main(rest)
|
|
85
|
+
if args.command in ("hook", "setup"):
|
|
86
|
+
from . import hooks
|
|
87
|
+
|
|
88
|
+
return hooks.main(rest) if args.command == "hook" else hooks.setup_main(rest)
|
|
89
|
+
if args.command == "inspect":
|
|
90
|
+
from .inspector import main as inspect_main
|
|
91
|
+
|
|
92
|
+
return inspect_main(rest)
|
|
93
|
+
return show_main(rest) if args.command == "show" else trace_main(rest)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _open(args: argparse.Namespace) -> "Memory":
|
|
97
|
+
from .memory import Memory
|
|
98
|
+
|
|
99
|
+
return Memory(args.store, args.engine, project=args.project, policy=policy_from(args))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def show_main(argv: Optional[list[str]] = None) -> int:
|
|
103
|
+
from .agent_tools import run_sync
|
|
104
|
+
from .views import session_lines, session_view, user_lines, user_view
|
|
105
|
+
|
|
106
|
+
import argparse
|
|
107
|
+
|
|
108
|
+
p = argparse.ArgumentParser(prog="commonmemo show", description="Show what memory holds, read-only.")
|
|
109
|
+
p.add_argument("what", choices=("user", "session"))
|
|
110
|
+
p.add_argument("id")
|
|
111
|
+
args = memory_options(p, agent=None).parse_args(argv)
|
|
112
|
+
mem = _open(args)
|
|
113
|
+
try:
|
|
114
|
+
if args.what == "user":
|
|
115
|
+
lines = user_lines(user_view(mem, args.id))
|
|
116
|
+
else:
|
|
117
|
+
view = session_view(mem, args.id)
|
|
118
|
+
if view is None:
|
|
119
|
+
print(f"no session {args.id!r}", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
lines = session_lines(view)
|
|
122
|
+
print("\n".join(lines))
|
|
123
|
+
return 0
|
|
124
|
+
finally:
|
|
125
|
+
run_sync(mem.aclose())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def trace_main(argv: Optional[list[str]] = None) -> int:
|
|
129
|
+
from .agent_tools import run_sync
|
|
130
|
+
from .views import session_lines, session_view
|
|
131
|
+
|
|
132
|
+
import argparse
|
|
133
|
+
|
|
134
|
+
p = argparse.ArgumentParser(prog="commonmemo trace", description="What each push gave the agent, and why.")
|
|
135
|
+
p.add_argument("session")
|
|
136
|
+
p.add_argument("--why", metavar="KEY", help="trace one pushed item (a fact key, world fact key or id) to its sources")
|
|
137
|
+
args = memory_options(p, agent=None).parse_args(argv)
|
|
138
|
+
mem = _open(args)
|
|
139
|
+
try:
|
|
140
|
+
if args.why:
|
|
141
|
+
found = mem.why(args.why, session_id=args.session)
|
|
142
|
+
if found is None:
|
|
143
|
+
print(f"{args.why!r} was never pushed in session {args.session!r}", file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
print("\n".join(found.lines()))
|
|
146
|
+
return 0
|
|
147
|
+
view = session_view(mem, args.session)
|
|
148
|
+
if view is None:
|
|
149
|
+
print(f"no session {args.session!r}", file=sys.stderr)
|
|
150
|
+
return 1
|
|
151
|
+
print("\n".join(session_lines(view, traces_only=True)))
|
|
152
|
+
return 0
|
|
153
|
+
finally:
|
|
154
|
+
run_sync(mem.aclose())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
sys.exit(main())
|