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 +3 -0
- waymark/ai.py +288 -0
- waymark/backup.py +483 -0
- waymark/cli.py +2196 -0
- waymark/config.py +108 -0
- waymark/diagnostics.py +69 -0
- waymark/drafting.py +100 -0
- waymark/exports.py +125 -0
- waymark/imports.py +812 -0
- waymark/journey.py +354 -0
- waymark/memory.py +46 -0
- waymark/model_setup.py +68 -0
- waymark/paths.py +40 -0
- waymark/reflection.py +556 -0
- waymark/retrieval.py +107 -0
- waymark/runtime.py +85 -0
- waymark/storage.py +1323 -0
- waymark/system.py +157 -0
- waymark/today.py +137 -0
- waymark/tui.py +2390 -0
- waymark_memory-0.3.0.dist-info/METADATA +154 -0
- waymark_memory-0.3.0.dist-info/RECORD +25 -0
- waymark_memory-0.3.0.dist-info/WHEEL +4 -0
- waymark_memory-0.3.0.dist-info/entry_points.txt +2 -0
- waymark_memory-0.3.0.dist-info/licenses/LICENSE +21 -0
waymark/__init__.py
ADDED
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()}..."
|