waymark-memory 0.3.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.
waymark/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Waymark package."""
2
+
3
+ __version__ = "0.3.0"
waymark/ai.py ADDED
@@ -0,0 +1,288 @@
1
+ """Optional local AI helpers for memory structuring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, cast
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ from waymark.memory import MemoryDraft, fallback_summary, fallback_title, parse_tags
12
+
13
+ DEFAULT_OLLAMA_ENDPOINT = "http://127.0.0.1:11434"
14
+ MAX_MODEL_TAGS = 5
15
+ MAX_TAG_LENGTH = 40
16
+ MAX_MEMORY_TYPE_LENGTH = 32
17
+
18
+ MEMORY_STRUCTURE_SYSTEM_PROMPT = """You structure one personal memory.
19
+ Return compact JSON only with: title, summary, type, tags.
20
+ Do not add facts that are not present in the memory.
21
+ Use 2-5 lowercase tags."""
22
+
23
+
24
+ class LocalAiError(RuntimeError):
25
+ """Raised when an optional local AI draft cannot be produced."""
26
+
27
+
28
+ def structure_memory_with_ollama(
29
+ raw_text: str,
30
+ *,
31
+ memory_type: str,
32
+ raw_tags: str,
33
+ model: str,
34
+ endpoint: str = DEFAULT_OLLAMA_ENDPOINT,
35
+ timeout_seconds: float = 30,
36
+ ) -> MemoryDraft:
37
+ """Ask Ollama for a memory draft and parse it into Waymark's draft shape."""
38
+
39
+ response_text = ollama_chat(
40
+ model=model,
41
+ messages=[
42
+ {"role": "system", "content": MEMORY_STRUCTURE_SYSTEM_PROMPT},
43
+ {
44
+ "role": "user",
45
+ "content": build_memory_structure_prompt(
46
+ raw_text,
47
+ memory_type=memory_type,
48
+ raw_tags=raw_tags,
49
+ ),
50
+ },
51
+ ],
52
+ endpoint=endpoint,
53
+ timeout_seconds=timeout_seconds,
54
+ )
55
+ return parse_memory_structure_response(
56
+ response_text,
57
+ raw_text=raw_text,
58
+ fallback_memory_type=memory_type,
59
+ raw_tags=raw_tags,
60
+ )
61
+
62
+
63
+ def embed_text_with_ollama(
64
+ text: str,
65
+ *,
66
+ model: str,
67
+ endpoint: str = DEFAULT_OLLAMA_ENDPOINT,
68
+ timeout_seconds: float = 30,
69
+ ) -> tuple[float, ...]:
70
+ """Ask Ollama for one embedding vector using the current /api/embed endpoint."""
71
+
72
+ payload = {
73
+ "model": model,
74
+ "input": text,
75
+ }
76
+ request = Request(
77
+ f"{endpoint.rstrip('/')}/api/embed",
78
+ data=json.dumps(payload).encode("utf-8"),
79
+ headers={"Content-Type": "application/json"},
80
+ method="POST",
81
+ )
82
+
83
+ try:
84
+ response: Any = urlopen(request, timeout=timeout_seconds)
85
+ with response:
86
+ body = cast(bytes, response.read())
87
+ except HTTPError as error:
88
+ raise LocalAiError(f"Ollama returned HTTP {error.code}.") from error
89
+ except URLError as error:
90
+ raise LocalAiError(f"Ollama is not reachable: {error.reason}.") from error
91
+ except TimeoutError as error:
92
+ raise LocalAiError("Ollama request timed out.") from error
93
+ except OSError as error:
94
+ raise LocalAiError(str(error)) from error
95
+
96
+ return parse_ollama_embed_response(body)
97
+
98
+
99
+ def parse_ollama_embed_response(body: bytes) -> tuple[float, ...]:
100
+ try:
101
+ data = json.loads(body.decode("utf-8"))
102
+ embeddings = data["embeddings"]
103
+ except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError) as error:
104
+ raise LocalAiError("Ollama returned an unexpected embedding response shape.") from error
105
+
106
+ if (
107
+ not isinstance(embeddings, list)
108
+ or not embeddings
109
+ or not isinstance(embeddings[0], list)
110
+ or not embeddings[0]
111
+ ):
112
+ raise LocalAiError("Ollama returned an empty embedding vector.")
113
+
114
+ try:
115
+ return tuple(float(value) for value in embeddings[0])
116
+ except (TypeError, ValueError) as error:
117
+ raise LocalAiError("Ollama returned a non-numeric embedding vector.") from error
118
+
119
+
120
+ def build_memory_structure_prompt(raw_text: str, *, memory_type: str, raw_tags: str) -> str:
121
+ return (
122
+ "Draft a memory card for this saved memory.\n"
123
+ f"Requested type: {memory_type.strip() or 'daily'}\n"
124
+ f"User tags: {raw_tags.strip() or 'none'}\n\n"
125
+ "Memory:\n"
126
+ f"{raw_text.strip()}"
127
+ )
128
+
129
+
130
+ def ollama_chat(
131
+ *,
132
+ model: str,
133
+ messages: list[dict[str, str]],
134
+ endpoint: str = DEFAULT_OLLAMA_ENDPOINT,
135
+ timeout_seconds: float = 30,
136
+ ) -> str:
137
+ payload = {
138
+ "model": model,
139
+ "messages": messages,
140
+ "stream": False,
141
+ "format": "json",
142
+ }
143
+ request = Request(
144
+ f"{endpoint.rstrip('/')}/api/chat",
145
+ data=json.dumps(payload).encode("utf-8"),
146
+ headers={"Content-Type": "application/json"},
147
+ method="POST",
148
+ )
149
+
150
+ try:
151
+ response: Any = urlopen(request, timeout=timeout_seconds)
152
+ with response:
153
+ body = cast(bytes, response.read())
154
+ except HTTPError as error:
155
+ raise LocalAiError(f"Ollama returned HTTP {error.code}.") from error
156
+ except URLError as error:
157
+ raise LocalAiError(f"Ollama is not reachable: {error.reason}.") from error
158
+ except TimeoutError as error:
159
+ raise LocalAiError("Ollama request timed out.") from error
160
+ except OSError as error:
161
+ raise LocalAiError(str(error)) from error
162
+
163
+ try:
164
+ data = json.loads(body.decode("utf-8"))
165
+ content = data["message"]["content"]
166
+ except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError) as error:
167
+ raise LocalAiError("Ollama returned an unexpected response shape.") from error
168
+
169
+ if not isinstance(content, str) or not content.strip():
170
+ raise LocalAiError("Ollama returned an empty memory draft.")
171
+ return content
172
+
173
+
174
+ def parse_memory_structure_response(
175
+ response_text: str,
176
+ *,
177
+ raw_text: str,
178
+ fallback_memory_type: str,
179
+ raw_tags: str = "",
180
+ ) -> MemoryDraft:
181
+ data = parse_json_object(response_text)
182
+ clean_text = raw_text.strip()
183
+ fallback_type = normalize_memory_type(fallback_memory_type)
184
+ clean_type = normalize_memory_type(
185
+ data.get("type") or data.get("memory_type"),
186
+ fallback=fallback_type,
187
+ )
188
+ model_tags = normalize_model_tags(data.get("tags"))
189
+ tags = tuple(sorted(set(parse_tags(raw_tags)) | set(model_tags)))
190
+
191
+ return MemoryDraft(
192
+ raw_text=clean_text,
193
+ memory_type=clean_type,
194
+ title=clean_text_field(data.get("title"), fallback=fallback_title(clean_text), limit=72),
195
+ summary=clean_text_field(
196
+ data.get("summary"),
197
+ fallback=fallback_summary(clean_text),
198
+ limit=240,
199
+ ),
200
+ tags=tags,
201
+ )
202
+
203
+
204
+ def parse_json_object(response_text: str) -> dict[str, Any]:
205
+ stripped = response_text.strip()
206
+ if stripped.startswith("```"):
207
+ stripped = strip_code_fence(stripped)
208
+
209
+ start = stripped.find("{")
210
+ end = stripped.rfind("}")
211
+ if start == -1 or end == -1 or end < start:
212
+ raise LocalAiError("Local AI did not return a JSON object.")
213
+
214
+ payload = stripped[start : end + 1]
215
+ try:
216
+ data = json.loads(payload)
217
+ except json.JSONDecodeError as error:
218
+ repaired_payload = repair_json_payload(payload)
219
+ try:
220
+ data = json.loads(repaired_payload)
221
+ except json.JSONDecodeError:
222
+ raise LocalAiError("Local AI returned invalid JSON.") from error
223
+ if not isinstance(data, dict):
224
+ raise LocalAiError("Local AI did not return a JSON object.")
225
+ return cast(dict[str, Any], data)
226
+
227
+
228
+ def strip_code_fence(text: str) -> str:
229
+ lines = text.splitlines()
230
+ if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```":
231
+ return "\n".join(lines[1:-1]).strip()
232
+ return text
233
+
234
+
235
+ def normalize_model_tags(value: object) -> tuple[str, ...]:
236
+ if isinstance(value, str):
237
+ tags = parse_tags(value)
238
+ return clean_model_tags(tags)
239
+ if isinstance(value, list):
240
+ tags = parse_tags(",".join(str(item) for item in value if str(item).strip()))
241
+ return clean_model_tags(tags)
242
+ return ()
243
+
244
+
245
+ def clean_model_tags(tags: tuple[str, ...]) -> tuple[str, ...]:
246
+ clean_tags = tuple(
247
+ normalized
248
+ for tag in tags
249
+ if (normalized := normalize_slug(tag, max_length=MAX_TAG_LENGTH))
250
+ )
251
+ return clean_tags[:MAX_MODEL_TAGS]
252
+
253
+
254
+ def normalize_memory_type(value: object, *, fallback: str = "daily") -> str:
255
+ normalized = normalize_slug(value, max_length=MAX_MEMORY_TYPE_LENGTH)
256
+ if normalized:
257
+ return normalized
258
+ fallback_type = normalize_slug(fallback, max_length=MAX_MEMORY_TYPE_LENGTH)
259
+ return fallback_type or "daily"
260
+
261
+
262
+ def normalize_slug(value: object, *, max_length: int) -> str:
263
+ if not isinstance(value, str):
264
+ return ""
265
+ lowered = value.strip().lower()
266
+ if not lowered:
267
+ return ""
268
+ normalized = re.sub(r"[^a-z0-9_-]+", "-", lowered)
269
+ normalized = re.sub(r"-{2,}", "-", normalized).strip("-_")
270
+ if len(normalized) <= max_length:
271
+ return normalized
272
+ return normalized[:max_length].rstrip("-_")
273
+
274
+
275
+ def repair_json_payload(payload: str) -> str:
276
+ return re.sub(r",\s*([}\]])", r"\1", payload)
277
+
278
+
279
+ def clean_text_field(value: object, *, fallback: str, limit: int) -> str:
280
+ if isinstance(value, str):
281
+ text = " ".join(value.strip().split())
282
+ else:
283
+ text = ""
284
+ if not text:
285
+ text = fallback
286
+ if len(text) <= limit:
287
+ return text
288
+ return f"{text[: limit - 3].rstrip()}..."