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.
- outmem/__init__.py +51 -0
- outmem/_logfire.py +54 -0
- outmem/_sqlite.py +23 -0
- outmem/_store/__init__.py +0 -0
- outmem/_store/import_vault.py +308 -0
- outmem/_store/semantic.py +275 -0
- outmem/_store/sources.py +105 -0
- outmem/_time.py +45 -0
- outmem/adapters/__init__.py +10 -0
- outmem/adapters/pydantic_ai.py +803 -0
- outmem/agent/__init__.py +45 -0
- outmem/agent/approval.py +271 -0
- outmem/agent/prompts/system.j2 +183 -0
- outmem/agent/runtime.py +269 -0
- outmem/agent/service.py +342 -0
- outmem/backlinks.py +176 -0
- outmem/cli/__init__.py +12 -0
- outmem/cli/__main__.py +1046 -0
- outmem/config.py +506 -0
- outmem/dashboard/__init__.py +24 -0
- outmem/dashboard/app.py +33 -0
- outmem/dashboard/router.py +167 -0
- outmem/dashboard/service.py +60 -0
- outmem/dashboard/templates/base.html.j2 +94 -0
- outmem/dashboard/templates/wiki_history.html.j2 +19 -0
- outmem/dashboard/templates/wiki_index.html.j2 +16 -0
- outmem/dashboard/templates/wiki_page.html.j2 +38 -0
- outmem/exceptions.py +81 -0
- outmem/frontmatter.py +230 -0
- outmem/git_ops.py +494 -0
- outmem/history.py +69 -0
- outmem/identity.py +109 -0
- outmem/index.py +97 -0
- outmem/lint.py +399 -0
- outmem/observability.py +53 -0
- outmem/search.py +223 -0
- outmem/semantic/__init__.py +46 -0
- outmem/semantic/chunker.py +154 -0
- outmem/semantic/embeddings.py +97 -0
- outmem/semantic/store.py +452 -0
- outmem/semantic/testing.py +139 -0
- outmem/skills/notes/evolution/SKILL.md +96 -0
- outmem/skills/notes/evolution/references/git-log.md +50 -0
- outmem/skills/notes/search/SKILL.md +106 -0
- outmem/skills/notes/search/references/patterns.md +50 -0
- outmem/skills/notes/write/SKILL.md +156 -0
- outmem/skills/notes/write/references/commit-grammar.md +43 -0
- outmem/skills/notes/write/references/frontmatter.md +65 -0
- outmem/skills/notes/write/references/rebase-loop.md +50 -0
- outmem/skills.py +40 -0
- outmem/slug.py +73 -0
- outmem/sources.py +392 -0
- outmem/state.py +173 -0
- outmem/store.py +922 -0
- outmem-0.1.0.dist-info/METADATA +458 -0
- outmem-0.1.0.dist-info/RECORD +59 -0
- outmem-0.1.0.dist-info/WHEEL +4 -0
- outmem-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
|