waymark-memory 0.3.0__tar.gz
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_memory-0.3.0/.gitignore +26 -0
- waymark_memory-0.3.0/LICENSE +21 -0
- waymark_memory-0.3.0/PKG-INFO +154 -0
- waymark_memory-0.3.0/README.md +112 -0
- waymark_memory-0.3.0/pyproject.toml +86 -0
- waymark_memory-0.3.0/src/waymark/__init__.py +3 -0
- waymark_memory-0.3.0/src/waymark/ai.py +288 -0
- waymark_memory-0.3.0/src/waymark/backup.py +483 -0
- waymark_memory-0.3.0/src/waymark/cli.py +2196 -0
- waymark_memory-0.3.0/src/waymark/config.py +108 -0
- waymark_memory-0.3.0/src/waymark/diagnostics.py +69 -0
- waymark_memory-0.3.0/src/waymark/drafting.py +100 -0
- waymark_memory-0.3.0/src/waymark/exports.py +125 -0
- waymark_memory-0.3.0/src/waymark/imports.py +812 -0
- waymark_memory-0.3.0/src/waymark/journey.py +354 -0
- waymark_memory-0.3.0/src/waymark/memory.py +46 -0
- waymark_memory-0.3.0/src/waymark/model_setup.py +68 -0
- waymark_memory-0.3.0/src/waymark/paths.py +40 -0
- waymark_memory-0.3.0/src/waymark/reflection.py +556 -0
- waymark_memory-0.3.0/src/waymark/retrieval.py +107 -0
- waymark_memory-0.3.0/src/waymark/runtime.py +85 -0
- waymark_memory-0.3.0/src/waymark/storage.py +1323 -0
- waymark_memory-0.3.0/src/waymark/system.py +157 -0
- waymark_memory-0.3.0/src/waymark/today.py +137 -0
- waymark_memory-0.3.0/src/waymark/tui.py +2390 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
.wheel-smoke/
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*.pyo
|
|
6
|
+
*.pyd
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
site/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
|
|
17
|
+
# Local runtime state
|
|
18
|
+
.waymark-local/
|
|
19
|
+
.env
|
|
20
|
+
.env.*
|
|
21
|
+
|
|
22
|
+
# OS/editor noise
|
|
23
|
+
.DS_Store
|
|
24
|
+
Thumbs.db
|
|
25
|
+
.idea/
|
|
26
|
+
.vscode/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Waymark Contributors
|
|
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.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: waymark-memory
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A local-first terminal companion for personal memory, reflection, and decisions.
|
|
5
|
+
Project-URL: Homepage, https://shusingh.github.io/waymark/
|
|
6
|
+
Project-URL: Documentation, https://shusingh.github.io/waymark/
|
|
7
|
+
Project-URL: Repository, https://github.com/shusingh/waymark
|
|
8
|
+
Project-URL: Issues, https://github.com/shusingh/waymark/issues
|
|
9
|
+
Author: Waymark Contributors
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: local-first,memory,notes,reflection,sqlite,terminal
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Text Processing
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: platformdirs>=4.2
|
|
25
|
+
Requires-Dist: rich>=13.7
|
|
26
|
+
Requires-Dist: textual>=0.80
|
|
27
|
+
Requires-Dist: typer>=0.12
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
31
|
+
Requires-Dist: pypdf>=4.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
35
|
+
Provides-Extra: docs
|
|
36
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
37
|
+
Requires-Dist: mkdocs-typer2[mkdocs]>=0.1; extra == 'docs'
|
|
38
|
+
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
39
|
+
Provides-Extra: pdf
|
|
40
|
+
Requires-Dist: pypdf>=4.0; extra == 'pdf'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# Waymark
|
|
44
|
+
|
|
45
|
+
Waymark is a local-first terminal companion for capturing personal memories,
|
|
46
|
+
tracking decisions, reflecting on patterns, and asking grounded questions about
|
|
47
|
+
your life over time.
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
capture messy thought -> structure memory card -> store locally -> retrieve with sources -> reflect over time
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Full documentation lives at **https://shusingh.github.io/waymark/**.
|
|
54
|
+
The PyPI package name is **`waymark-memory`**; the installed command remains
|
|
55
|
+
**`waymark`**.
|
|
56
|
+
The complete CLI reference is generated from the Typer app, so use the docs site
|
|
57
|
+
instead of hand-maintained command lists.
|
|
58
|
+
|
|
59
|
+
## What Works
|
|
60
|
+
|
|
61
|
+
- Guided Textual interface for today, capture, ask, timeline, memory detail,
|
|
62
|
+
reflection, decisions, import, export, backup, and doctor checks.
|
|
63
|
+
- Local SQLite storage with source citations, saved reflections, linked
|
|
64
|
+
decisions, local backups, and portable bundles.
|
|
65
|
+
- Explicit imports for Markdown, text, PDF text layers, DOCX paragraphs, and
|
|
66
|
+
bounded preview-first folder batches.
|
|
67
|
+
- Optional local AI through Ollama for memory structuring and semantic retrieval.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
Recommended install:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pipx install waymark-memory
|
|
75
|
+
waymark --version
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Current GitHub Release:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python -m pip install https://github.com/shusingh/waymark/releases/download/v0.3.0/waymark_memory-0.3.0-py3-none-any.whl
|
|
82
|
+
waymark --version
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Platform-specific commands are in
|
|
86
|
+
[Installation & Updates](docs/guides/installation.md).
|
|
87
|
+
|
|
88
|
+
From source on Windows PowerShell:
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
py -m venv .venv
|
|
92
|
+
.\.venv\Scripts\Activate.ps1
|
|
93
|
+
python -m pip install -e ".[dev,docs,pdf]"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
From source on macOS/Linux:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python3 -m venv .venv
|
|
100
|
+
source .venv/bin/activate
|
|
101
|
+
python -m pip install -e ".[dev,docs,pdf]"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## First Minute
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
waymark
|
|
108
|
+
waymark today
|
|
109
|
+
waymark capture --type project "Shipped the first import flow today."
|
|
110
|
+
waymark timeline
|
|
111
|
+
waymark ask "import flow"
|
|
112
|
+
waymark reflect --period week
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Start with [Getting Started](docs/getting-started.md), or open the live
|
|
116
|
+
[CLI reference](https://shusingh.github.io/waymark/reference/cli/).
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
Runtime data defaults to `~/.waymark`. For local development, keep test data out
|
|
121
|
+
of your real profile:
|
|
122
|
+
|
|
123
|
+
```powershell
|
|
124
|
+
$env:WAYMARK_HOME = "D:\Code\waymark\.waymark-local\runtime"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Before committing code changes:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
ruff check .
|
|
131
|
+
mypy src
|
|
132
|
+
pytest -q
|
|
133
|
+
mkdocs build --strict
|
|
134
|
+
python -m build
|
|
135
|
+
python -m twine check dist/*
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
CI and release jobs also run a fresh wheel install smoke test:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python -m venv .wheel-smoke
|
|
142
|
+
. .wheel-smoke/bin/activate
|
|
143
|
+
python -m pip install dist/*.whl
|
|
144
|
+
waymark --version
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Product Guardrails
|
|
148
|
+
|
|
149
|
+
- Local-first by default.
|
|
150
|
+
- Manual capture works without AI models.
|
|
151
|
+
- AI-generated summaries, tags, and reflections require user confirmation.
|
|
152
|
+
- Answers must cite saved memories or imported sources.
|
|
153
|
+
- No large model downloads, file scans, OCR, or indexing jobs without explicit
|
|
154
|
+
approval.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Waymark
|
|
2
|
+
|
|
3
|
+
Waymark is a local-first terminal companion for capturing personal memories,
|
|
4
|
+
tracking decisions, reflecting on patterns, and asking grounded questions about
|
|
5
|
+
your life over time.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
capture messy thought -> structure memory card -> store locally -> retrieve with sources -> reflect over time
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Full documentation lives at **https://shusingh.github.io/waymark/**.
|
|
12
|
+
The PyPI package name is **`waymark-memory`**; the installed command remains
|
|
13
|
+
**`waymark`**.
|
|
14
|
+
The complete CLI reference is generated from the Typer app, so use the docs site
|
|
15
|
+
instead of hand-maintained command lists.
|
|
16
|
+
|
|
17
|
+
## What Works
|
|
18
|
+
|
|
19
|
+
- Guided Textual interface for today, capture, ask, timeline, memory detail,
|
|
20
|
+
reflection, decisions, import, export, backup, and doctor checks.
|
|
21
|
+
- Local SQLite storage with source citations, saved reflections, linked
|
|
22
|
+
decisions, local backups, and portable bundles.
|
|
23
|
+
- Explicit imports for Markdown, text, PDF text layers, DOCX paragraphs, and
|
|
24
|
+
bounded preview-first folder batches.
|
|
25
|
+
- Optional local AI through Ollama for memory structuring and semantic retrieval.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Recommended install:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install waymark-memory
|
|
33
|
+
waymark --version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Current GitHub Release:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python -m pip install https://github.com/shusingh/waymark/releases/download/v0.3.0/waymark_memory-0.3.0-py3-none-any.whl
|
|
40
|
+
waymark --version
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Platform-specific commands are in
|
|
44
|
+
[Installation & Updates](docs/guides/installation.md).
|
|
45
|
+
|
|
46
|
+
From source on Windows PowerShell:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
py -m venv .venv
|
|
50
|
+
.\.venv\Scripts\Activate.ps1
|
|
51
|
+
python -m pip install -e ".[dev,docs,pdf]"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
From source on macOS/Linux:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python3 -m venv .venv
|
|
58
|
+
source .venv/bin/activate
|
|
59
|
+
python -m pip install -e ".[dev,docs,pdf]"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## First Minute
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
waymark
|
|
66
|
+
waymark today
|
|
67
|
+
waymark capture --type project "Shipped the first import flow today."
|
|
68
|
+
waymark timeline
|
|
69
|
+
waymark ask "import flow"
|
|
70
|
+
waymark reflect --period week
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Start with [Getting Started](docs/getting-started.md), or open the live
|
|
74
|
+
[CLI reference](https://shusingh.github.io/waymark/reference/cli/).
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
Runtime data defaults to `~/.waymark`. For local development, keep test data out
|
|
79
|
+
of your real profile:
|
|
80
|
+
|
|
81
|
+
```powershell
|
|
82
|
+
$env:WAYMARK_HOME = "D:\Code\waymark\.waymark-local\runtime"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Before committing code changes:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
ruff check .
|
|
89
|
+
mypy src
|
|
90
|
+
pytest -q
|
|
91
|
+
mkdocs build --strict
|
|
92
|
+
python -m build
|
|
93
|
+
python -m twine check dist/*
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
CI and release jobs also run a fresh wheel install smoke test:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m venv .wheel-smoke
|
|
100
|
+
. .wheel-smoke/bin/activate
|
|
101
|
+
python -m pip install dist/*.whl
|
|
102
|
+
waymark --version
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Product Guardrails
|
|
106
|
+
|
|
107
|
+
- Local-first by default.
|
|
108
|
+
- Manual capture works without AI models.
|
|
109
|
+
- AI-generated summaries, tags, and reflections require user confirmation.
|
|
110
|
+
- Answers must cite saved memories or imported sources.
|
|
111
|
+
- No large model downloads, file scans, OCR, or indexing jobs without explicit
|
|
112
|
+
approval.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling<1.30"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "waymark-memory"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "A local-first terminal companion for personal memory, reflection, and decisions."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Waymark Contributors" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["local-first", "memory", "notes", "reflection", "sqlite", "terminal"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Text Processing",
|
|
26
|
+
"Topic :: Utilities"
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"platformdirs>=4.2",
|
|
30
|
+
"rich>=13.7",
|
|
31
|
+
"typer>=0.12",
|
|
32
|
+
"textual>=0.80"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://shusingh.github.io/waymark/"
|
|
37
|
+
Documentation = "https://shusingh.github.io/waymark/"
|
|
38
|
+
Repository = "https://github.com/shusingh/waymark"
|
|
39
|
+
Issues = "https://github.com/shusingh/waymark/issues"
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
pdf = [
|
|
43
|
+
"pypdf>=4.0"
|
|
44
|
+
]
|
|
45
|
+
docs = [
|
|
46
|
+
"mkdocs>=1.6",
|
|
47
|
+
"mkdocs-material>=9.5",
|
|
48
|
+
"mkdocs-typer2[mkdocs]>=0.1"
|
|
49
|
+
]
|
|
50
|
+
dev = [
|
|
51
|
+
"build>=1.2",
|
|
52
|
+
"mypy>=1.10",
|
|
53
|
+
"pypdf>=4.0",
|
|
54
|
+
"pytest>=8.2",
|
|
55
|
+
"ruff>=0.5",
|
|
56
|
+
"twine>=5.0"
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[project.scripts]
|
|
60
|
+
waymark = "waymark.cli:app"
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = ["src/waymark"]
|
|
64
|
+
|
|
65
|
+
[tool.hatch.build.targets.sdist]
|
|
66
|
+
include = [
|
|
67
|
+
"src/waymark",
|
|
68
|
+
"README.md",
|
|
69
|
+
"LICENSE",
|
|
70
|
+
"pyproject.toml"
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.ruff]
|
|
74
|
+
line-length = 100
|
|
75
|
+
target-version = "py311"
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint]
|
|
78
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
79
|
+
|
|
80
|
+
[tool.mypy]
|
|
81
|
+
python_version = "3.11"
|
|
82
|
+
strict = true
|
|
83
|
+
warn_unused_configs = true
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
testpaths = ["tests"]
|
|
@@ -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()}..."
|