dial-memory 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.
File without changes
@@ -0,0 +1,278 @@
1
+ """Dial — file-based MemoryProvider implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from strawpot.memory.protocol import (
11
+ ContextCard,
12
+ ControlSignal,
13
+ DumpReceipt,
14
+ GetResult,
15
+ MemoryKind,
16
+ RememberResult,
17
+ )
18
+
19
+ from .scorer import score_and_filter
20
+ from .storage import (
21
+ append_jsonl,
22
+ em_path,
23
+ expand_path,
24
+ knowledge_path,
25
+ read_jsonl,
26
+ read_jsonl_tail,
27
+ role_knowledge_path,
28
+ )
29
+
30
+
31
+ class DialMemoryProvider:
32
+ """Default file-based memory provider for StrawPot."""
33
+
34
+ name = "dial"
35
+
36
+ def __init__(self, config: dict | None = None):
37
+ cfg = config or {}
38
+ self._storage_dir = expand_path(
39
+ cfg.get("storage_dir", ".strawpot/memory/dial-data")
40
+ )
41
+ self._global_dir = expand_path(
42
+ cfg.get("global_storage_dir", "~/.strawpot/memory/dial-data")
43
+ )
44
+ self._em_tail_count: int = int(cfg.get("em_tail_count", 20))
45
+ self._em_max_events: int = int(cfg.get("em_max_events", 10000))
46
+ self._rm_min_score: float = float(cfg.get("rm_min_score", 0.3))
47
+
48
+ # -- get ------------------------------------------------------------------
49
+
50
+ def get(
51
+ self,
52
+ *,
53
+ session_id: str,
54
+ agent_id: str,
55
+ role: str,
56
+ behavior_ref: str,
57
+ task: str,
58
+ budget: int | None = None,
59
+ parent_agent_id: str | None = None,
60
+ ) -> GetResult:
61
+ cards: list[ContextCard] = []
62
+ sources: list[str] = []
63
+
64
+ # 1. Collect knowledge from all scopes
65
+ all_entries = self._collect_knowledge(role)
66
+
67
+ # 2. SM — entries without keywords (always included)
68
+ sm_entries = [e for e in all_entries if not e.get("keywords")]
69
+ if sm_entries:
70
+ cards.append(
71
+ ContextCard(
72
+ kind=MemoryKind.SM,
73
+ content=_format_knowledge(sm_entries),
74
+ source="knowledge",
75
+ )
76
+ )
77
+ sources.append("sm")
78
+
79
+ # 3. RM — entries with keywords (conditionally included)
80
+ rm_entries = [e for e in all_entries if e.get("keywords")]
81
+ rm_matches = score_and_filter(rm_entries, task, self._rm_min_score)
82
+ if rm_matches:
83
+ cards.append(
84
+ ContextCard(
85
+ kind=MemoryKind.RM,
86
+ content=_format_knowledge(rm_matches),
87
+ source="knowledge",
88
+ )
89
+ )
90
+ sources.append("rm")
91
+
92
+ # 4. EM — recent session events
93
+ em_events = read_jsonl_tail(
94
+ em_path(self._storage_dir, session_id), self._em_tail_count
95
+ )
96
+ if em_events:
97
+ cards.append(
98
+ ContextCard(
99
+ kind=MemoryKind.EM,
100
+ content=_format_em(em_events),
101
+ source="em",
102
+ )
103
+ )
104
+ sources.append("em")
105
+
106
+ # 5. Budget trimming
107
+ if budget is not None:
108
+ cards = _trim_to_budget(cards, budget)
109
+
110
+ return GetResult(
111
+ context_cards=cards,
112
+ control_signals=ControlSignal(),
113
+ sources_used=sources,
114
+ )
115
+
116
+ # -- dump -----------------------------------------------------------------
117
+
118
+ def dump(
119
+ self,
120
+ *,
121
+ session_id: str,
122
+ agent_id: str,
123
+ role: str,
124
+ behavior_ref: str,
125
+ task: str,
126
+ status: str,
127
+ output: str,
128
+ tool_trace: str = "",
129
+ parent_agent_id: str | None = None,
130
+ artifacts: dict[str, str] | None = None,
131
+ ) -> DumpReceipt:
132
+ event_id = _make_id("evt")
133
+ event = {
134
+ "event_id": event_id,
135
+ "ts": _now_iso(),
136
+ "session_id": session_id,
137
+ "agent_id": agent_id,
138
+ "role": role,
139
+ "event_type": "AGENT_RESULT",
140
+ "data": {
141
+ "task": task,
142
+ "status": status,
143
+ "summary": output[:500] if output else "",
144
+ },
145
+ }
146
+ append_jsonl(em_path(self._storage_dir, session_id), event)
147
+ return DumpReceipt(em_event_ids=[event_id])
148
+
149
+ # -- remember -------------------------------------------------------------
150
+
151
+ def remember(
152
+ self,
153
+ *,
154
+ session_id: str,
155
+ agent_id: str,
156
+ role: str,
157
+ content: str,
158
+ keywords: list[str] | None = None,
159
+ scope: str = "project",
160
+ ) -> RememberResult:
161
+ kw = keywords or []
162
+ store_path = self._knowledge_store_path(scope, role)
163
+
164
+ # Dedup: exact content match
165
+ existing = read_jsonl(store_path)
166
+ for entry in existing:
167
+ if entry.get("content") == content:
168
+ return RememberResult(status="duplicate", entry_id=entry.get("entry_id", ""))
169
+
170
+ entry_id = _make_id("k")
171
+ entry = {
172
+ "entry_id": entry_id,
173
+ "content": content,
174
+ "keywords": kw,
175
+ "source": agent_id,
176
+ "ts": _now_iso(),
177
+ }
178
+ append_jsonl(store_path, entry)
179
+ return RememberResult(status="accepted", entry_id=entry_id)
180
+
181
+ # -- internal helpers -----------------------------------------------------
182
+
183
+ def _collect_knowledge(self, role: str) -> list[dict]:
184
+ """Merge knowledge from global, project, and role scopes; deduplicate."""
185
+ global_entries = read_jsonl(knowledge_path(self._global_dir))
186
+ project_entries = read_jsonl(knowledge_path(self._storage_dir))
187
+ role_entries = read_jsonl(role_knowledge_path(self._storage_dir, role))
188
+
189
+ all_entries = global_entries + project_entries + role_entries
190
+ return _deduplicate(all_entries)
191
+
192
+ def _knowledge_store_path(self, scope: str, role: str) -> Path:
193
+ """Return the knowledge.jsonl path for the given scope."""
194
+ if scope == "global":
195
+ return knowledge_path(self._global_dir)
196
+ elif scope == "role":
197
+ return role_knowledge_path(self._storage_dir, role)
198
+ else: # "project" (default)
199
+ return knowledge_path(self._storage_dir)
200
+
201
+
202
+ # -- Formatting helpers -------------------------------------------------------
203
+
204
+
205
+ def _format_knowledge(entries: list[dict]) -> str:
206
+ """Format knowledge entries as readable text for context cards."""
207
+ lines = []
208
+ for e in entries:
209
+ lines.append(f"- {e['content']}")
210
+ return "\n".join(lines)
211
+
212
+
213
+ def _format_em(events: list[dict]) -> str:
214
+ """Format EM events as readable text for context cards."""
215
+ lines = []
216
+ for ev in events:
217
+ data = ev.get("data", {})
218
+ role = ev.get("role", "")
219
+ status = data.get("status", "")
220
+ task = data.get("task", "")
221
+ summary = data.get("summary", "")
222
+ line = f"[{role}] {task}"
223
+ if status:
224
+ line += f" ({status})"
225
+ if summary and summary != task:
226
+ line += f": {summary[:200]}"
227
+ lines.append(line)
228
+ return "\n".join(lines)
229
+
230
+
231
+ def _trim_to_budget(cards: list[ContextCard], budget: int) -> list[ContextCard]:
232
+ """Trim cards to fit within a character budget. EM trimmed first."""
233
+ total = sum(len(c.content) for c in cards)
234
+ if total <= budget:
235
+ return cards
236
+
237
+ # Trim EM first, then RM
238
+ result = list(cards)
239
+ for kind in (MemoryKind.EM, MemoryKind.RM, MemoryKind.SM):
240
+ if total <= budget:
241
+ break
242
+ for i, card in enumerate(result):
243
+ if card.kind == kind and total > budget:
244
+ excess = total - budget
245
+ if excess >= len(card.content):
246
+ total -= len(card.content)
247
+ result[i] = ContextCard(kind=card.kind, content="", source=card.source)
248
+ else:
249
+ result[i] = ContextCard(
250
+ kind=card.kind,
251
+ content=card.content[: len(card.content) - excess],
252
+ source=card.source,
253
+ )
254
+ total = budget
255
+
256
+ return [c for c in result if c.content]
257
+
258
+
259
+ def _deduplicate(entries: list[dict]) -> list[dict]:
260
+ """Deduplicate entries by content, keeping first occurrence."""
261
+ seen: set[str] = set()
262
+ result = []
263
+ for e in entries:
264
+ content = e.get("content", "")
265
+ if content not in seen:
266
+ seen.add(content)
267
+ result.append(e)
268
+ return result
269
+
270
+
271
+ def _make_id(prefix: str) -> str:
272
+ """Generate a short unique ID with prefix."""
273
+ return f"{prefix}_{uuid.uuid4().hex[:8]}"
274
+
275
+
276
+ def _now_iso() -> str:
277
+ """Return current UTC time as ISO 8601 string."""
278
+ return datetime.now(timezone.utc).isoformat()
dial_memory/scorer.py ADDED
@@ -0,0 +1,47 @@
1
+ """RM keyword relevance scoring — simple keyword overlap, no embeddings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # Common English stop words to ignore during tokenization.
8
+ _STOP_WORDS = frozenset(
9
+ "a an and are as at be by for from has have in is it of on or that the "
10
+ "this to was were will with".split()
11
+ )
12
+
13
+ _TOKEN_RE = re.compile(r"[a-z0-9_]+")
14
+
15
+
16
+ def tokenize(text: str) -> set[str]:
17
+ """Split text into lowercase tokens, stripping stop words."""
18
+ tokens = set(_TOKEN_RE.findall(text.lower()))
19
+ return tokens - _STOP_WORDS
20
+
21
+
22
+ def score_entry(entry: dict, task_text: str) -> float:
23
+ """Score a knowledge entry against task text using keyword overlap.
24
+
25
+ Returns a float between 0.0 and 1.0. Higher means more relevant.
26
+ """
27
+ entry_keywords = entry.get("keywords", [])
28
+ if not entry_keywords:
29
+ return 0.0
30
+
31
+ task_tokens = tokenize(task_text)
32
+ kw_set = {kw.lower() for kw in entry_keywords}
33
+ overlap = task_tokens & kw_set
34
+ return len(overlap) / len(kw_set)
35
+
36
+
37
+ def score_and_filter(
38
+ entries: list[dict], task_text: str, min_score: float = 0.3
39
+ ) -> list[dict]:
40
+ """Score entries and return those above *min_score*, sorted descending."""
41
+ scored = []
42
+ for entry in entries:
43
+ s = score_entry(entry, task_text)
44
+ if s >= min_score:
45
+ scored.append((s, entry))
46
+ scored.sort(key=lambda t: t[0], reverse=True)
47
+ return [entry for _, entry in scored]
dial_memory/storage.py ADDED
@@ -0,0 +1,69 @@
1
+ """File I/O helpers for dial — JSONL read/write, directory setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def ensure_dir(path: Path) -> None:
11
+ """Create directory (and parents) if it doesn't exist."""
12
+ path.mkdir(parents=True, exist_ok=True)
13
+
14
+
15
+ def append_jsonl(path: Path, record: dict) -> None:
16
+ """Append a single JSON record to a JSONL file."""
17
+ ensure_dir(path.parent)
18
+ with open(path, "a", encoding="utf-8") as f:
19
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
20
+
21
+
22
+ def read_jsonl(path: Path) -> list[dict]:
23
+ """Read all records from a JSONL file. Returns [] if file doesn't exist."""
24
+ if not path.is_file():
25
+ return []
26
+ records = []
27
+ with open(path, encoding="utf-8") as f:
28
+ for line in f:
29
+ line = line.strip()
30
+ if line:
31
+ records.append(json.loads(line))
32
+ return records
33
+
34
+
35
+ def read_jsonl_tail(path: Path, count: int) -> list[dict]:
36
+ """Read the last *count* records from a JSONL file."""
37
+ if not path.is_file():
38
+ return []
39
+ lines: list[str] = []
40
+ with open(path, encoding="utf-8") as f:
41
+ for raw in f:
42
+ stripped = raw.strip()
43
+ if stripped:
44
+ lines.append(stripped)
45
+ tail = lines[-count:] if count else lines
46
+ return [json.loads(line) for line in tail]
47
+
48
+
49
+ def expand_path(path_str: str) -> Path:
50
+ """Expand ~ and env vars in a path string."""
51
+ return Path(os.path.expandvars(os.path.expanduser(path_str)))
52
+
53
+
54
+ # -- Path builders -----------------------------------------------------------
55
+
56
+
57
+ def em_path(storage_dir: Path, session_id: str) -> Path:
58
+ """Path to the EM event log for a session."""
59
+ return storage_dir / "em" / f"{session_id}.jsonl"
60
+
61
+
62
+ def knowledge_path(storage_dir: Path) -> Path:
63
+ """Path to the knowledge JSONL at a given scope root."""
64
+ return storage_dir / "knowledge" / "knowledge.jsonl"
65
+
66
+
67
+ def role_knowledge_path(storage_dir: Path, role: str) -> Path:
68
+ """Path to role-scoped knowledge JSONL."""
69
+ return storage_dir / "knowledge" / "roles" / role / "knowledge.jsonl"
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: dial-memory
3
+ Version: 0.1.1
4
+ Summary: Default file-based memory provider for StrawPot
5
+ Project-URL: Homepage, https://github.com/strawpot/dial
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-cov; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Dial
15
+
16
+ Default file-based memory provider for [StrawPot](https://github.com/strawpot/strawpot).
17
+
18
+ Two memory layers — **Event Memory** and a unified **Knowledge store** —
19
+ using local JSON/JSONL files. Zero external dependencies.
20
+
21
+ ## Quick Start
22
+
23
+ ```toml
24
+ # strawpot.toml
25
+ memory = "dial"
26
+ ```
27
+
28
+ ## How It Works
29
+
30
+ - **EM** — Append-only event log per session. Fully automatic, no agent cooperation needed.
31
+ - **Knowledge (SM)** — Facts and conventions, always included. Scoped to global, project, or role.
32
+ - **Knowledge (RM)** — Domain-specific entries, included only when the task keywords match.
33
+
34
+ Knowledge is scoped at three levels:
35
+
36
+ | Scope | Example |
37
+ |-------|---------|
38
+ | **Global** | "Always use conventional commits" |
39
+ | **Project** | "This project uses pytest" |
40
+ | **Role** | "Check migration dir before modifying models" |
41
+
42
+ Agents write knowledge via the denden `remember` RPC during execution:
43
+
44
+ ```bash
45
+ denden send '{"remember": {"content": "This project uses pytest", "scope": "project"}}'
46
+ denden send '{"remember": {"content": "Payments API needs idempotency keys", "keywords": ["payment", "stripe"]}}'
47
+ ```
48
+
49
+ Entries are deduplicated and written directly to the knowledge store.
50
+
51
+ See [DESIGN.md](DESIGN.md) for architecture details.
52
+
53
+ ## License
54
+
55
+ [MIT](LICENSE)
@@ -0,0 +1,8 @@
1
+ dial_memory/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ dial_memory/provider.py,sha256=oy9AWvW-ZFJfHadVQhkvxId0kZY-PADXOmnoHrv9AHI,8702
3
+ dial_memory/scorer.py,sha256=B7krPo56LJ8iWPUwiP_GoSyfGFpw2hd6MqZjvIHIrRE,1445
4
+ dial_memory/storage.py,sha256=8cVmowPWFjulQaBFeY4hKrp6arMSs_OjUjG6O6rULbU,2126
5
+ dial_memory-0.1.1.dist-info/METADATA,sha256=gC56tz4DaH1aQKSI4C6GA4Yq_wUnFvaCsGWXZTU_UMw,1661
6
+ dial_memory-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ dial_memory-0.1.1.dist-info/licenses/LICENSE,sha256=3kmwvBd_YyLUyC9H-DI797wdrZDvqH1u0pEEKP5nmJ8,1065
8
+ dial_memory-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 strawpot
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.