plug-play-memory 0.2.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.
@@ -0,0 +1,337 @@
1
+ """
2
+ Event emitter — the SDK agents call to record meaningful actions.
3
+
4
+ Each emit() goes through:
5
+ 1. Judge evaluation (rules + heuristics + salience)
6
+ 2. Policy gate (does this agent have permission to write_memory from this source?)
7
+ 3. Deduplication against existing memory
8
+ 4. MemoryStore.remember() with salience + tagged metadata
9
+ 5. Audit log entry
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from .events import AgentEvent
17
+ from .judge import Judge
18
+ from .policy import PolicyEngine
19
+ from .storage import MemoryStore
20
+
21
+
22
+ class EventEmitter:
23
+ """Agents call helpers on this; the system handles judging + policy + storage."""
24
+
25
+ def __init__(
26
+ self,
27
+ store: MemoryStore,
28
+ policy: PolicyEngine,
29
+ judge: Judge,
30
+ agent_id: str,
31
+ agent_type: str,
32
+ ):
33
+ self.store = store
34
+ self.policy = policy
35
+ self.judge = judge
36
+ self.agent_id = agent_id
37
+ self.agent_type = agent_type
38
+
39
+ # ------------------------------------------------------------------
40
+ # Public API — agents call these
41
+ # ------------------------------------------------------------------
42
+
43
+ def emit(
44
+ self,
45
+ action: str,
46
+ inputs: dict[str, Any] | None = None,
47
+ outputs: dict[str, Any] | None = None,
48
+ outcome: str = "success",
49
+ raw: str = "",
50
+ ):
51
+ """Generic emit. Use this for actions without a dedicated helper."""
52
+ return self._emit(action, inputs or {}, outputs or {}, outcome, raw)
53
+
54
+ # ---- CODING helpers ----
55
+
56
+ def file_edited(self, path: str, diff_summary: str = "", lines_changed: int = 0, success: bool = True):
57
+ return self._emit(
58
+ action="file_edited",
59
+ inputs={"path": path, "lines_changed": lines_changed},
60
+ outputs={"diff_summary": diff_summary},
61
+ outcome="success" if success else "failure",
62
+ raw=diff_summary,
63
+ )
64
+
65
+ def git_pushed(self, branch: str, sha: str, repo: str, message: str = ""):
66
+ return self._emit(
67
+ action="git_pushed",
68
+ inputs={"branch": branch, "repo": repo},
69
+ outputs={"sha": sha, "message": message},
70
+ outcome="success",
71
+ raw=message,
72
+ )
73
+
74
+ def test_run(self, suite: str, passed: int, failed: int, failures: list[str] | None = None):
75
+ outcome = "success" if failed == 0 else ("partial" if passed > 0 else "failure")
76
+ return self._emit(
77
+ action="test_run",
78
+ inputs={"suite": suite, "passed": passed, "failed": failed},
79
+ outputs={"failures": failures or []},
80
+ outcome=outcome,
81
+ raw="; ".join(failures or []),
82
+ )
83
+
84
+ def decision_made(self, topic: str, choice: str, reason: str = ""):
85
+ return self._emit(
86
+ action="decision_made",
87
+ inputs={"topic": topic},
88
+ outputs={"choice": choice, "reason": reason},
89
+ outcome="success",
90
+ raw=f"Decided {choice} for {topic}. Reason: {reason}",
91
+ )
92
+
93
+ def error_encountered(self, message: str, context: dict[str, Any] | None = None):
94
+ return self._emit(
95
+ action="error_encountered",
96
+ inputs=context or {},
97
+ outputs={"error": message},
98
+ outcome="failure",
99
+ raw=message,
100
+ )
101
+
102
+ def deployed(self, target: str, version: str, success: bool, notes: str = ""):
103
+ return self._emit(
104
+ action="deployed",
105
+ inputs={"target": target, "version": version},
106
+ outputs={"success": success, "notes": notes},
107
+ outcome="success" if success else "failure",
108
+ raw=notes,
109
+ )
110
+
111
+ def migration_run(self, name: str, success: bool, error: str = ""):
112
+ return self._emit(
113
+ action="migration_run",
114
+ inputs={"migration": name},
115
+ outputs={"success": success, "error": error},
116
+ outcome="success" if success else "failure",
117
+ raw=error or f"Migration {name} completed",
118
+ )
119
+
120
+ # ---- STUDY helpers ----
121
+
122
+ def fact_observed(self, field_name: str, value: str, raw: str = ""):
123
+ # Use field_name as the dedup key so repeated observations
124
+ # of the same field update rather than accumulate.
125
+ from .judge import JudgeDecision
126
+ decision = self.judge.evaluate(AgentEvent(
127
+ agent_id=self.agent_id, agent_type=self.agent_type,
128
+ action="fact_observed", outcome="success",
129
+ inputs={"field": field_name},
130
+ outputs={"value": value},
131
+ raw=raw or f"{field_name}: {value}",
132
+ ))
133
+ if decision.should_save and not decision.key:
134
+ decision.key = field_name
135
+ # Bypass _emit's re-judging and write directly
136
+ if not decision.should_save:
137
+ self.store.audit.log(
138
+ self.agent_id, "event_discarded",
139
+ {"action": "fact_observed", "reason": decision.discard_reason},
140
+ )
141
+ return None
142
+ # Apply same policy + dedup + write pipeline as _emit
143
+ return self._write_decision(decision, "fact_observed", {"field": field_name},
144
+ {"value": value}, "success", raw or f"{field_name}: {value}")
145
+
146
+ def question_asked(self, topic: str, question: str, difficulty: str = "normal"):
147
+ return self._emit(
148
+ action="question_asked",
149
+ inputs={"topic": topic, "difficulty": difficulty},
150
+ outputs={"question": question},
151
+ outcome="success",
152
+ raw=question,
153
+ )
154
+
155
+ def answer_attempted(
156
+ self,
157
+ topic: str,
158
+ correct: bool,
159
+ misconception: str = "",
160
+ question_id: str = "",
161
+ student_answer: str = "",
162
+ ):
163
+ outcome = "success" if correct else "failure"
164
+ return self._emit(
165
+ action="answer_attempted",
166
+ inputs={"topic": topic, "question_id": question_id},
167
+ outputs={
168
+ "correct": correct,
169
+ "misconception": misconception,
170
+ "student_answer": student_answer,
171
+ },
172
+ outcome=outcome,
173
+ raw=f"Student answer: {student_answer}. Misconception: {misconception}" if misconception else f"Student answer: {student_answer}",
174
+ )
175
+
176
+ def concept_mastered(self, topic: str, mastery_level: float, attempts: int = 1):
177
+ return self._emit(
178
+ action="concept_mastered",
179
+ outcome="success",
180
+ inputs={"topic": topic, "attempts": attempts},
181
+ outputs={"mastery_level": mastery_level},
182
+ raw=f"Mastered {topic} at {mastery_level:.0%} after {attempts} attempts",
183
+ )
184
+
185
+ def knowledge_gap_found(self, topic: str, gap: str, severity: str = "high"):
186
+ return self._emit(
187
+ action="knowledge_gap_found",
188
+ outcome="failure",
189
+ inputs={"topic": topic, "severity": severity},
190
+ outputs={"gap": gap},
191
+ raw=gap,
192
+ )
193
+
194
+ def goal_set(self, description: str, deadline: str = "", priority: str = "normal"):
195
+ return self._emit(
196
+ action="goal_set",
197
+ outcome="success",
198
+ inputs={"deadline": deadline, "priority": priority},
199
+ outputs={"description": description},
200
+ raw=description,
201
+ )
202
+
203
+ # ---- FINANCIAL helpers ----
204
+
205
+ def make_payment(self, amount: float, recipient: str, success: bool, notes: str = ""):
206
+ return self._emit(
207
+ action="make_payment",
208
+ inputs={"amount": amount, "recipient": recipient},
209
+ outputs={"success": success, "notes": notes},
210
+ outcome="success" if success else "failure",
211
+ raw=notes or f"Payment of {amount} to {recipient}",
212
+ )
213
+
214
+ def set_budget_limit(self, category: str, limit: float, period: str = "monthly"):
215
+ return self._emit(
216
+ action="set_budget_limit",
217
+ outcome="success",
218
+ inputs={"category": category, "limit": limit, "period": period},
219
+ outputs={},
220
+ raw=f"Budget {category}: {limit} per {period}",
221
+ )
222
+
223
+ # ------------------------------------------------------------------
224
+ # Core emit logic — judge → policy → dedup → store
225
+ # ------------------------------------------------------------------
226
+
227
+ def _emit(
228
+ self,
229
+ action: str,
230
+ inputs: dict[str, Any],
231
+ outputs: dict[str, Any],
232
+ outcome: str,
233
+ raw: str,
234
+ ):
235
+ event = AgentEvent(
236
+ agent_id=self.agent_id,
237
+ agent_type=self.agent_type,
238
+ action=action,
239
+ inputs=inputs,
240
+ outputs=outputs,
241
+ outcome=outcome,
242
+ raw=raw,
243
+ )
244
+
245
+ # Step 1: judge
246
+ decision = self.judge.evaluate(event)
247
+
248
+ if not decision.should_save:
249
+ self.store.audit.log(
250
+ self.agent_id, "event_discarded",
251
+ {
252
+ "action": action, "outcome": outcome,
253
+ "reason": decision.discard_reason,
254
+ },
255
+ )
256
+ return None
257
+
258
+ return self._write_decision(decision, action, inputs, outputs, outcome, raw)
259
+
260
+ def _write_decision(
261
+ self,
262
+ decision,
263
+ action: str,
264
+ inputs: dict[str, Any],
265
+ outputs: dict[str, Any],
266
+ outcome: str,
267
+ raw: str,
268
+ ):
269
+ """Shared write path: policy gate → dedup → store → audit. Used by _emit and fact_observed."""
270
+
271
+ # Step 2: policy gate — agent must have write_memory permission for this category
272
+ # M5 fix: pass agent_type explicitly to avoid shared-state mutation
273
+ policy_decision = self.policy.check(
274
+ self.agent_id, "write_memory",
275
+ {
276
+ "source": "event_judge",
277
+ "action": action,
278
+ "category": decision.category,
279
+ "tier": decision.tier,
280
+ },
281
+ agent_type=self.agent_type,
282
+ )
283
+ # Log as permission_check so coverage_manifest picks it up
284
+ self.store.audit.log(
285
+ self.agent_id, "permission_check",
286
+ {
287
+ "action": "write_memory",
288
+ "context": {
289
+ "source": "event_judge",
290
+ "event_action": action,
291
+ "category": decision.category,
292
+ "tier": decision.tier,
293
+ },
294
+ "backend": "layered_yaml",
295
+ "reason": policy_decision.reason,
296
+ "layers": policy_decision.layers_evaluated,
297
+ },
298
+ allowed=policy_decision.allowed,
299
+ )
300
+ if not policy_decision.allowed:
301
+ return None
302
+
303
+ # Step 3: dedup — skip if same key + same value already exists
304
+ if decision.key and self._is_duplicate(decision.key, decision.summary):
305
+ self.store.audit.log(
306
+ self.agent_id, "event_deduplicated",
307
+ {"action": action, "key": decision.key},
308
+ )
309
+ return None
310
+
311
+ # Step 4: write with rich metadata
312
+ fact = self.store.remember(
313
+ agent_id=self.agent_id,
314
+ value=decision.summary,
315
+ tier=decision.tier,
316
+ key=decision.key,
317
+ importance=decision.importance,
318
+ salience_score=decision.salience_score,
319
+ tagged=decision.tagged,
320
+ metadata={
321
+ "source": "event_judge",
322
+ "agent_type": self.agent_type,
323
+ "action": action,
324
+ "category": decision.category,
325
+ "outcome": outcome,
326
+ "inputs": inputs,
327
+ "outputs": outputs,
328
+ },
329
+ )
330
+ return fact
331
+
332
+ def _is_duplicate(self, key: str, value: str) -> bool:
333
+ """Check ALL tiers for duplicate (M3 fix — was core-only)."""
334
+ for f in self.store.all_facts(self.agent_id):
335
+ if f.key == key and f.value.lower() == value.lower():
336
+ return True
337
+ return False
@@ -0,0 +1,36 @@
1
+ """Structured events — the unit of memory input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class AgentEvent:
13
+ """A structured thing an agent did. This is what the judge evaluates."""
14
+ agent_id: str
15
+ agent_type: str # "coding" | "study" | "financial" | ...
16
+ action: str # "file_edited" | "git_pushed" | "answer_attempted" | ...
17
+ inputs: dict[str, Any] = field(default_factory=dict)
18
+ outputs: dict[str, Any] = field(default_factory=dict)
19
+ outcome: str = "success" # "success" | "failure" | "partial"
20
+ raw: str = ""
21
+ timestamp: float = field(default_factory=time.time)
22
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
23
+
24
+ def to_prompt(self) -> str:
25
+ lines = [
26
+ f"AGENT_TYPE: {self.agent_type}",
27
+ f"ACTION: {self.action}",
28
+ f"OUTCOME: {self.outcome}",
29
+ ]
30
+ if self.inputs:
31
+ lines.append(f"INPUTS: {self.inputs}")
32
+ if self.outputs:
33
+ lines.append(f"OUTPUTS: {self.outputs}")
34
+ if self.raw:
35
+ lines.append(f"RAW: {self.raw[:300]}")
36
+ return "\n".join(lines)