outmem 0.1.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.
Files changed (59) hide show
  1. outmem/__init__.py +51 -0
  2. outmem/_logfire.py +54 -0
  3. outmem/_sqlite.py +23 -0
  4. outmem/_store/__init__.py +0 -0
  5. outmem/_store/import_vault.py +308 -0
  6. outmem/_store/semantic.py +275 -0
  7. outmem/_store/sources.py +105 -0
  8. outmem/_time.py +45 -0
  9. outmem/adapters/__init__.py +10 -0
  10. outmem/adapters/pydantic_ai.py +803 -0
  11. outmem/agent/__init__.py +45 -0
  12. outmem/agent/approval.py +271 -0
  13. outmem/agent/prompts/system.j2 +183 -0
  14. outmem/agent/runtime.py +269 -0
  15. outmem/agent/service.py +342 -0
  16. outmem/backlinks.py +176 -0
  17. outmem/cli/__init__.py +12 -0
  18. outmem/cli/__main__.py +1046 -0
  19. outmem/config.py +506 -0
  20. outmem/dashboard/__init__.py +24 -0
  21. outmem/dashboard/app.py +33 -0
  22. outmem/dashboard/router.py +167 -0
  23. outmem/dashboard/service.py +60 -0
  24. outmem/dashboard/templates/base.html.j2 +94 -0
  25. outmem/dashboard/templates/wiki_history.html.j2 +19 -0
  26. outmem/dashboard/templates/wiki_index.html.j2 +16 -0
  27. outmem/dashboard/templates/wiki_page.html.j2 +38 -0
  28. outmem/exceptions.py +81 -0
  29. outmem/frontmatter.py +230 -0
  30. outmem/git_ops.py +494 -0
  31. outmem/history.py +69 -0
  32. outmem/identity.py +109 -0
  33. outmem/index.py +97 -0
  34. outmem/lint.py +399 -0
  35. outmem/observability.py +53 -0
  36. outmem/search.py +223 -0
  37. outmem/semantic/__init__.py +46 -0
  38. outmem/semantic/chunker.py +154 -0
  39. outmem/semantic/embeddings.py +97 -0
  40. outmem/semantic/store.py +452 -0
  41. outmem/semantic/testing.py +139 -0
  42. outmem/skills/notes/evolution/SKILL.md +96 -0
  43. outmem/skills/notes/evolution/references/git-log.md +50 -0
  44. outmem/skills/notes/search/SKILL.md +106 -0
  45. outmem/skills/notes/search/references/patterns.md +50 -0
  46. outmem/skills/notes/write/SKILL.md +156 -0
  47. outmem/skills/notes/write/references/commit-grammar.md +43 -0
  48. outmem/skills/notes/write/references/frontmatter.md +65 -0
  49. outmem/skills/notes/write/references/rebase-loop.md +50 -0
  50. outmem/skills.py +40 -0
  51. outmem/slug.py +73 -0
  52. outmem/sources.py +392 -0
  53. outmem/state.py +173 -0
  54. outmem/store.py +922 -0
  55. outmem-0.1.0.dist-info/METADATA +458 -0
  56. outmem-0.1.0.dist-info/RECORD +59 -0
  57. outmem-0.1.0.dist-info/WHEEL +4 -0
  58. outmem-0.1.0.dist-info/entry_points.txt +2 -0
  59. outmem-0.1.0.dist-info/licenses/LICENSE +21 -0
outmem/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """outmem — agentic RAG memory over a git-versioned wiki.
2
+
3
+ Public API::
4
+
5
+ from outmem import WikiStore, WikiPage, AgentIdentity
6
+
7
+ store = WikiStore.open("/srv/agent")
8
+ hits = store.search("pricing formula")
9
+ page = store.read("pricing-formula")
10
+ store.extend_page("pricing-formula", body="…")
11
+ store.append_log(topic="pricing", content="noticed an inconsistency")
12
+
13
+ See ``docs/`` for the conceptual rationale, the v0.1 spec, and integration
14
+ patterns. See ``src/outmem/skills/notes/`` for the skills a downstream agent
15
+ loads to learn the search / evolution / write workflows.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from outmem.exceptions import (
21
+ ConflictError,
22
+ FrontmatterError,
23
+ GitOperationError,
24
+ IdentityWarning,
25
+ OutmemError,
26
+ SlugError,
27
+ WritebackError,
28
+ )
29
+ from outmem.frontmatter import ProvenanceEntry, WikiFrontmatter
30
+ from outmem.observability import setup_logfire
31
+ from outmem.store import AgentIdentity, WikiPage, WikiStore, WikiStoreConfig
32
+
33
+ __version__ = "0.1.0.dev0"
34
+
35
+ __all__ = [
36
+ "AgentIdentity",
37
+ "ConflictError",
38
+ "FrontmatterError",
39
+ "GitOperationError",
40
+ "IdentityWarning",
41
+ "OutmemError",
42
+ "ProvenanceEntry",
43
+ "SlugError",
44
+ "WikiFrontmatter",
45
+ "WikiPage",
46
+ "WikiStore",
47
+ "WikiStoreConfig",
48
+ "WritebackError",
49
+ "__version__",
50
+ "setup_logfire",
51
+ ]
outmem/_logfire.py ADDED
@@ -0,0 +1,54 @@
1
+ """Optional Pydantic Logfire wiring.
2
+
3
+ :func:`setup` is a CLI-side hook — call it once per invocation with the
4
+ config's :class:`LogfireSettings`. When the ``project`` field is
5
+ ``None`` (default), this is a no-op and the ``logfire`` dep doesn't
6
+ have to be installed. When it's set, we configure Logfire with
7
+ ``service_name="outmem"`` (so traces are tagged distinctly from other
8
+ services publishing to the same project) and instrument pydantic_ai.
9
+
10
+ The auth token comes from ``$LOGFIRE_TOKEN`` (read by
11
+ ``logfire.configure`` itself), which is also what routes data to a
12
+ specific project — Logfire's API doesn't accept a project-name kwarg.
13
+ The config's ``project`` field is therefore an opt-in marker plus
14
+ self-documentation of which project the user expects to feed; routing
15
+ is the token's job.
16
+
17
+ Missing dep with ``project`` configured raises :class:`OutmemError`
18
+ rather than silently degrading — the user asked for instrumentation;
19
+ tell them how to install it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from outmem.config import LOGFIRE_SERVICE_NAME, LogfireSettings
25
+ from outmem.exceptions import OutmemError
26
+
27
+ _configured = False
28
+
29
+
30
+ def setup(settings: LogfireSettings) -> bool:
31
+ """Configure Logfire if ``settings.project`` is set. Returns ``True``
32
+ when instrumentation was activated, ``False`` when disabled."""
33
+ global _configured
34
+
35
+ if settings.project is None:
36
+ return False
37
+ if _configured:
38
+ return True
39
+
40
+ try:
41
+ import logfire
42
+ except ImportError as exc:
43
+ raise OutmemError(
44
+ "logfire.project is set in config.yaml but the `logfire` "
45
+ "package is not installed. Run: pip install 'outmem[logfire]'"
46
+ ) from exc
47
+
48
+ logfire.configure(
49
+ service_name=LOGFIRE_SERVICE_NAME,
50
+ send_to_logfire="if-token-present",
51
+ )
52
+ logfire.instrument_pydantic_ai()
53
+ _configured = True
54
+ return True
outmem/_sqlite.py ADDED
@@ -0,0 +1,23 @@
1
+ """Internal SQLite helpers — single source for the connection idiom.
2
+
3
+ outmem has two SQLite-backed stores (``.sources.db`` registry,
4
+ ``.vectors.db`` semantic index). Both want the same boilerplate:
5
+ ensure the parent dir exists, connect with ``check_same_thread=False``
6
+ (PydanticAI dispatches async work across threads), and use
7
+ :class:`sqlite3.Row` so call sites can use dict access. PRAGMAs and
8
+ schema initialisation stay with each caller — they differ enough that
9
+ abstracting them would add more friction than it saves.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sqlite3
15
+ from pathlib import Path
16
+
17
+
18
+ def connect(db_path: Path) -> sqlite3.Connection:
19
+ """Open / create a SQLite DB at ``db_path`` with outmem's defaults."""
20
+ db_path.parent.mkdir(parents=True, exist_ok=True)
21
+ con = sqlite3.connect(str(db_path), check_same_thread=False)
22
+ con.row_factory = sqlite3.Row
23
+ return con
File without changes
@@ -0,0 +1,308 @@
1
+ """``outmem import`` — bring an existing markdown vault into ``wiki/``.
2
+
3
+ The vault — typically an Obsidian directory tree — is recursively scanned
4
+ for ``*.md`` files. Each note becomes ``wiki/<slug>.md`` with generated
5
+ frontmatter; wikilinks are rewritten so they target the new flat slug
6
+ namespace; the whole import lands in one ``import: <vault-name>`` commit.
7
+
8
+ Public entry point: :func:`import_vault`. The CLI subcommand
9
+ ``outmem import <dir>`` is a thin wrapper.
10
+
11
+ Design notes
12
+ ------------
13
+
14
+ - **Flat slug namespace**. outmem's wiki is flat; collisions are
15
+ resolved by prefixing with the parent directory (deterministic,
16
+ sorted-order tiebreak).
17
+ - **Wikilink rewriting**. Obsidian's ``[[Note Name]]`` gets rewritten
18
+ to ``[[note-slug|Note Name]]`` — preserving the display text while
19
+ making the slug machine-resolvable. Wikilinks whose target doesn't
20
+ resolve to any imported slug are left untouched; ``outmem lint``
21
+ surfaces them.
22
+ - **Frontmatter**. ``title`` from the first H1 (or humanised filename
23
+ fallback), ``slug`` from the resolved name, ``created`` / ``updated``
24
+ from the file mtime, ``provenance`` recording the original vault-
25
+ relative path so the audit trail stays honest about origin.
26
+ - **One commit**. The whole import is atomic; revert is a single
27
+ ``git reset --hard HEAD^``.
28
+ - **Refuses non-empty wikis** unless ``force=True``. A re-import is
29
+ semantically "the vault is the canonical source, clobber the wiki" —
30
+ the agent's intermediate edits would be lost, so we require the
31
+ caller to acknowledge.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import re
38
+ import unicodedata
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime
41
+ from pathlib import Path
42
+ from typing import TYPE_CHECKING
43
+
44
+ from outmem.exceptions import OutmemError
45
+ from outmem.frontmatter import WikiFrontmatter, serialize_wiki_page
46
+ from outmem.index import editorial_pages
47
+ from outmem.slug import validate_slug
48
+
49
+ if TYPE_CHECKING:
50
+ from outmem.store import WikiStore
51
+
52
+ log = logging.getLogger(__name__)
53
+
54
+ _WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(#[^|\]]*)?(?:\|([^\]]*))?\]\]")
55
+ _H1_RE = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ImportSummary:
60
+ """Result of one :func:`import_vault` call."""
61
+
62
+ pages_imported: int
63
+ slug_collisions: tuple[tuple[str, str], ...] # (original_path, resolved_slug)
64
+ wikilinks_rewritten: int
65
+ wikilinks_unresolved: int
66
+
67
+
68
+ @dataclass
69
+ class _Candidate:
70
+ source_path: Path # absolute
71
+ rel_source: Path # relative to vault root
72
+ body: str # original markdown body
73
+ title: str
74
+ slug: str = "" # filled in by collision-resolution pass
75
+ mtime: datetime = field(default_factory=datetime.now)
76
+
77
+
78
+ def import_vault(
79
+ store: WikiStore,
80
+ source: Path,
81
+ *,
82
+ force: bool = False,
83
+ ) -> ImportSummary:
84
+ """Import every ``*.md`` under ``source`` into ``store``'s wiki.
85
+
86
+ Raises :class:`OutmemError` if ``source`` isn't a directory or if
87
+ the target wiki already has pages and ``force`` is False.
88
+ """
89
+ if not source.is_dir():
90
+ raise OutmemError(f"import source is not a directory: {source}")
91
+
92
+ existing = editorial_pages(store.wiki_path)
93
+ if existing and not force:
94
+ raise OutmemError(
95
+ f"target wiki already has {len(existing)} page(s); "
96
+ "pass force=True to overwrite."
97
+ )
98
+
99
+ candidates = _collect_candidates(source)
100
+ if not candidates:
101
+ raise OutmemError(f"no *.md files found under {source}")
102
+
103
+ _resolve_slugs(candidates)
104
+ slug_by_basename = _build_link_index(candidates)
105
+
106
+ collisions: list[tuple[str, str]] = []
107
+ rewrites_total = 0
108
+ unresolved_total = 0
109
+ written: list[str] = []
110
+
111
+ for c in candidates:
112
+ # Defence in depth: every produced slug must pass the canonical
113
+ # check, else outmem itself refuses to read the page back.
114
+ validate_slug(c.slug)
115
+ rewritten_body, rewrites, unresolved = _rewrite_wikilinks(
116
+ c.body, slug_by_basename
117
+ )
118
+ rewrites_total += rewrites
119
+ unresolved_total += unresolved
120
+
121
+ frontmatter = WikiFrontmatter(
122
+ title=c.title,
123
+ slug=c.slug,
124
+ created=c.mtime,
125
+ updated=c.mtime,
126
+ provenance=[{"path": str(c.rel_source), "source": "obsidian-import"}],
127
+ )
128
+ rendered = serialize_wiki_page(frontmatter, rewritten_body)
129
+ dest = store.wiki_path / f"{c.slug}.md"
130
+ dest.parent.mkdir(parents=True, exist_ok=True)
131
+ dest.write_text(rendered, encoding="utf-8")
132
+ written.append(f"{store.config.wiki_dir}/{c.slug}.md")
133
+
134
+ if c.slug != _slugify(c.rel_source.stem):
135
+ collisions.append((str(c.rel_source), c.slug))
136
+
137
+ # Regenerate the index + commit everything in one go.
138
+ store.rebuild_index(commit=False)
139
+ written.append(f"{store.config.wiki_dir}/index.md")
140
+ store._commit_paths(written, subject=f"import: {source.name}")
141
+
142
+ return ImportSummary(
143
+ pages_imported=len(candidates),
144
+ slug_collisions=tuple(collisions),
145
+ wikilinks_rewritten=rewrites_total,
146
+ wikilinks_unresolved=unresolved_total,
147
+ )
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Scanning / candidate building
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def _collect_candidates(source: Path) -> list[_Candidate]:
156
+ """Walk ``source`` for ``*.md``, ignoring hidden directories."""
157
+ out: list[_Candidate] = []
158
+ for path in sorted(source.rglob("*.md")):
159
+ rel = path.relative_to(source)
160
+ # Skip files whose path includes a hidden component
161
+ # (.obsidian/, .trash/, .git/, etc.).
162
+ if any(part.startswith(".") for part in rel.parts):
163
+ continue
164
+ try:
165
+ body = path.read_text(encoding="utf-8", errors="replace")
166
+ except OSError as exc:
167
+ log.warning("import: unreadable %s: %s", path, exc)
168
+ continue
169
+ title = _extract_title(body, fallback=rel.stem)
170
+ mtime = datetime.fromtimestamp(path.stat().st_mtime).astimezone()
171
+ out.append(
172
+ _Candidate(
173
+ source_path=path,
174
+ rel_source=rel,
175
+ body=body,
176
+ title=title,
177
+ mtime=mtime,
178
+ )
179
+ )
180
+ return out
181
+
182
+
183
+ def _extract_title(body: str, *, fallback: str) -> str:
184
+ """Pull the first ``# H1`` line, falling back to a humanised filename."""
185
+ match = _H1_RE.search(body)
186
+ if match:
187
+ return match.group(1).strip()
188
+ # Humanise: "my-great-note" → "My great note", "Pricing Formula" stays.
189
+ return fallback.replace("_", " ").replace("-", " ").strip() or fallback
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Slug resolution
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def _slugify(text: str) -> str:
198
+ """Filesystem-safe slug from arbitrary text.
199
+
200
+ Lowercases, strips diacritics, collapses whitespace / underscores to
201
+ hyphens, drops everything not ``[a-z0-9-]``, trims edge hyphens.
202
+ Returns an empty string for un-slugifiable input (e.g. emoji-only).
203
+ """
204
+ normalised = unicodedata.normalize("NFKD", text)
205
+ ascii_only = "".join(c for c in normalised if not unicodedata.combining(c))
206
+ lower = ascii_only.lower()
207
+ gapped = re.sub(r"[\s_]+", "-", lower)
208
+ filtered = re.sub(r"[^a-z0-9-]", "", gapped)
209
+ collapsed = re.sub(r"-+", "-", filtered)
210
+ return collapsed.strip("-")
211
+
212
+
213
+ def _resolve_slugs(candidates: list[_Candidate]) -> None:
214
+ """Assign a unique slug to every candidate, mutating in place.
215
+
216
+ Strategy:
217
+
218
+ 1. Compute the bare ``slugify(stem)`` for each.
219
+ 2. On collision, prefix with the parent directory slug; repeat if
220
+ the prefixed form also collides (walking further up the path).
221
+ 3. Final fallback: suffix with ``-2``, ``-3``, … so we never throw.
222
+ """
223
+ taken: set[str] = set()
224
+ for c in candidates:
225
+ candidate_slug = _propose_slug(c)
226
+ # Walk the path upward to disambiguate on collision.
227
+ parts = list(c.rel_source.parts[:-1]) # parent dirs, leaf-first
228
+ i = len(parts)
229
+ while candidate_slug in taken or not candidate_slug:
230
+ if i > 0:
231
+ i -= 1
232
+ prefix = _slugify(parts[i])
233
+ if not prefix:
234
+ continue
235
+ candidate_slug = f"{prefix}-{_propose_slug(c)}"
236
+ else:
237
+ # All parent dirs consumed; numeric suffix as last resort.
238
+ n = 2
239
+ base = candidate_slug or "page"
240
+ while f"{base}-{n}" in taken:
241
+ n += 1
242
+ candidate_slug = f"{base}-{n}"
243
+ break
244
+ c.slug = candidate_slug
245
+ taken.add(candidate_slug)
246
+
247
+
248
+ def _propose_slug(c: _Candidate) -> str:
249
+ """The bare slug from the file stem; guarded against empties."""
250
+ proposed = _slugify(c.rel_source.stem)
251
+ return proposed or "page"
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Wikilink rewriting
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ def _build_link_index(candidates: list[_Candidate]) -> dict[str, str]:
260
+ """Map ``basename`` → ``slug`` so wikilinks resolve by file name.
261
+
262
+ Obsidian's typical link form is ``[[Note Name]]`` (no path), which
263
+ Obsidian resolves to whatever file ``Note Name.md`` lives in. We
264
+ mirror that: indexed by the slugified stem.
265
+
266
+ On collision (two notes with the same basename in different
267
+ directories), the first one in sorted order wins the basename key;
268
+ others are reachable only by their resolved slug.
269
+ """
270
+ index: dict[str, str] = {}
271
+ for c in candidates:
272
+ key = _slugify(c.rel_source.stem)
273
+ index.setdefault(key, c.slug)
274
+ # Also index by the resolved slug itself so explicit
275
+ # ``[[parent-prefix-name]]`` references work.
276
+ index.setdefault(c.slug, c.slug)
277
+ return index
278
+
279
+
280
+ def _rewrite_wikilinks(
281
+ body: str, slug_by_basename: dict[str, str]
282
+ ) -> tuple[str, int, int]:
283
+ """Rewrite ``[[X]]`` and ``[[X|display]]`` to use resolved slugs.
284
+
285
+ Preserves display text by always emitting the ``[[slug|display]]``
286
+ form. Header anchors (``[[X#section]]``) are kept attached to the
287
+ rewritten slug. Unresolved links are returned unchanged; the
288
+ counter lets the caller report how many.
289
+ """
290
+ rewrites = 0
291
+ unresolved = 0
292
+
293
+ def _sub(match: re.Match[str]) -> str:
294
+ nonlocal rewrites, unresolved
295
+ target = match.group(1).strip()
296
+ anchor = match.group(2) or ""
297
+ display = (match.group(3) or target).strip()
298
+ key = _slugify(target)
299
+ slug = slug_by_basename.get(key)
300
+ if slug is None:
301
+ unresolved += 1
302
+ return match.group(0)
303
+ rewrites += 1
304
+ return f"[[{slug}{anchor}|{display}]]"
305
+
306
+ return _WIKILINK_RE.sub(_sub, body), rewrites, unresolved
307
+
308
+