Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Open Knowledge Format (OKF) support**: omind now reads and writes notes as a conformant [OKF v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) bundle — Google Cloud's vendor-neutral, Apache-2.0 Markdown-plus-YAML-frontmatter spec for sharing knowledge with agents and tools. Every note omind writes now leads with a YAML frontmatter block carrying the one field OKF requires (`type` — derived from the note's kind, e.g. `#feedback` → `Feedback`, when not declared) plus the recommended `title` / `description` / `tags` / `timestamp`; the `index.md` is the OKF directory listing. Producer-defined frontmatter keys are round-tripped (the spec requires preserving unknown keys).
- **`omind convert`**: migrate a pre-OKF vault to OKF in place — idempotent (a note already in OKF form is skipped, no mesh revision bump), with `--check` to validate the three conformance rules and `--dry-run` to preview. Also exposed programmatically as `omind.okf.convert_vault` / `omind.okf.check_conformance`.

### Changed
- Note parsing is now **dual-format and backward-compatible**: metadata is read from either the legacy `## Metadata` section (which still wins for shared fields, so un-upgraded mesh peers keep working) or the OKF frontmatter. Existing vaults keep working with no migration required; the `## Metadata` block is retained in every rendered note alongside the new frontmatter. The mesh merge driver carries the new `type` through three-way merges.

## [3.7.8] - 2026-07-01

### Fixed
Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ OMI/Obsidian memory tooling for AI agents: reproduce the integration on any mach
[![Codeberg](https://img.shields.io/badge/Codeberg-CryptoJones%2Fomind-2185D0?logo=codeberg&logoColor=white)](https://codeberg.org/CryptoJones/omind)
[![GitHub](https://img.shields.io/badge/GitHub-CryptoJones%2Fomind-181717?logo=github&logoColor=white)](https://github.com/CryptoJones/omind)
[![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![Open Knowledge Format](https://img.shields.io/badge/format-OKF%20v0.1-4285F4?logo=googlecloud&logoColor=white)](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf)
[![Version](https://img.shields.io/github/v/tag/CryptoJones/omind?label=version&color=orange)](https://github.com/CryptoJones/omind/tags)

> Mirrored on both [GitHub](https://github.com/CryptoJones/omind) and
Expand Down Expand Up @@ -205,17 +206,36 @@ every snapshot; losing it with the disk makes the backups unreadable.
## How memory is stored

OMI is just a folder of plain Markdown notes — one note per memory, fully
human-editable (open the folder as an Obsidian vault). Each note has a stable
shape so tools and the merge driver can read and write individual fields without
stepping on each other:
human-editable (open the folder as an Obsidian vault). It is also a conformant
**[Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf)
bundle** (see below). Each note has a stable shape so tools and the merge driver
can read and write individual fields without stepping on each other:

- a **YAML frontmatter block** — the note's OKF metadata: the required `type`
plus `title`, `description`, `tags`, and `timestamp`;
- a `# Title` and a `## Metadata` block (created date, `#tags`, and the mesh
`Rev:` Lamport stamp);
`Rev:` Lamport stamp), kept alongside the frontmatter so existing tooling and
un-upgraded mesh peers keep reading it unchanged;
- `## Summary` / `## Details` free text;
- `## Connections` — `[[wikilinks]]` to related notes (the graph the web UI and
`omind lint` traverse);
- `## Action Items` (a checkbox list) and `## References`.

**Open Knowledge Format (OKF).** omind speaks
[OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) —
Google Cloud's vendor-neutral, Apache-2.0
[specification](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)
for representing knowledge as a directory of Markdown files with YAML
frontmatter, readable by any agent or tool with no SDK, runtime, or lock-in.
Every note omind writes leads with a frontmatter block carrying the one field
OKF requires (`type`) plus the recommended `title` / `description` / `tags` /
`timestamp`, and `index.md` is the OKF directory listing — so an omind vault
drops straight into any OKF-aware consumer. Migrate a pre-OKF vault in place with
**`omind convert`** (idempotent; `--check` validates the three conformance rules,
`--dry-run` previews). More: the
[OKF spec](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)
and [okf.md](https://okf.md/).

**One writer, always.** Every write — `omind note`, the `omind node` MCP server an
agent calls, the web UI, the mesh merge — goes through a single locked, atomic
path: an advisory `flock`, an atomic `os.replace`, and a `note_version`
Expand Down
37 changes: 37 additions & 0 deletions src/omind/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,23 @@ def build_parser() -> argparse.ArgumentParser:
)
_add_vault_args(reindex)

convert = sub.add_parser(
"convert",
help="migrate an OMI vault to the Open Knowledge Format (OKF): give every "
"note YAML frontmatter with a 'type' (idempotent, in place)",
)
convert.add_argument(
"--dry-run",
action="store_true",
help="report what would change without writing any note",
)
convert.add_argument(
"--check",
action="store_true",
help="only check OKF conformance and report; make no changes",
)
_add_vault_args(convert)

note = sub.add_parser(
"note",
help="safely create or update one OMI note through OmiStore (the single-writer path)",
Expand Down Expand Up @@ -788,6 +805,24 @@ def _run_reindex(args: argparse.Namespace) -> int:
return 0


def _run_convert(args: argparse.Namespace) -> int:
from omind import okf

omi_dir = (args.vault / args.folder).expanduser()
if args.check:
report = okf.check_conformance(omi_dir)
else:
result = okf.convert_vault(omi_dir, dry_run=args.dry_run)
verb = "would convert" if args.dry_run else "converted"
print(f"{verb} {result.converted} note(s); {result.unchanged} already in OKF form")
report = result.report
for problem in report.problems:
print(f" [x] {problem.filename}: {problem.problem}")
tail = "all conformant" if report.ok else f"{len(report.problems)} non-conformant"
print(f"OKF v0.1 conformance: {report.conformant}/{report.concepts} concept notes — {tail}")
return 0 if report.ok else 1


def _run_search(args: argparse.Namespace) -> int:
from omind.store import OmiStore

Expand Down Expand Up @@ -1074,6 +1109,8 @@ def main(argv: list[str] | None = None) -> int:
return _run_checkpoint(args)
if args.command == "reindex":
return _run_reindex(args)
if args.command == "convert":
return _run_convert(args)
if args.command == "note":
return _run_note(args)
if args.command == "rollup":
Expand Down
1 change: 1 addition & 0 deletions src/omind/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ def scalar(name: str) -> str | bool:
# must never eat; carry them through with the same symmetric LWW rule.
frontmatter=str(scalar("frontmatter")),
lead=str(scalar("lead")),
okf_type=str(scalar("okf_type")),
rev=merged_rev,
disabled=bool(scalar("disabled")),
)
Expand Down
141 changes: 141 additions & 0 deletions src/omind/okf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Aaron K. Clark
"""OKF (Open Knowledge Format v0.1) conformance + conversion for an OMI vault.

OKF (Google Cloud, ``GoogleCloudPlatform/knowledge-catalog`` → ``okf/SPEC.md``)
represents a body of knowledge as a directory of markdown files, each carrying a
leading YAML frontmatter block whose one required field is ``type``. A bundle is
conformant (SPEC §9) iff:

1. every non-reserved ``.md`` file has a parseable YAML frontmatter block,
2. every such block has a non-empty ``type`` field, and
3. the reserved ``index.md`` / ``log.md`` follow their structure when present.

omind's OMI vault already IS such a bundle — :func:`omind.store.render_fields`
emits the frontmatter for every note it writes. This module (a) validates a
folder against the conformance rules and (b) migrates a *legacy* vault (whose
notes carried metadata only in a ``## Metadata`` section) into the frontmatter
form, in place and idempotently.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

from omind.paths import NON_CONSULT_FILENAMES
from omind.store import OmiStore, parse_frontmatter, parse_note, render_fields

#: Files that are NOT OKF concept documents, so the ``type``-required rule does
#: not apply: OKF reserves ``index.md`` / ``log.md`` (directory listing / change
#: log), and omind additionally treats its own scaffolding (``MEMORY.md``
#: recent-index, ``Memory Template.md``) as non-concept. Superset of the vault's
#: reserved names.
RESERVED_OKF_FILES = frozenset({"index.md", "log.md"}) | NON_CONSULT_FILENAMES


@dataclass
class OkfProblem:
"""A single conformance failure: which file, and what's wrong with it."""

filename: str
problem: str


@dataclass
class OkfReport:
"""Result of an OKF conformance scan over a vault folder."""

concepts: int = 0
conformant: int = 0
problems: list[OkfProblem] = field(default_factory=list)

@property
def ok(self) -> bool:
return not self.problems


def _leading_frontmatter_block(text: str) -> str:
"""Return the verbatim leading ``---`` … ``---`` block, or ``""`` if absent.

A block that opens with ``---`` but is never closed is not a valid
frontmatter block, so it returns ``""`` (reported as non-conformant) rather
than swallowing the whole document.
"""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return ""
out = [lines[0]]
for line in lines[1:]:
out.append(line)
if line.strip() == "---":
return "\n".join(out)
return ""


def check_conformance(omi_dir: Path | str) -> OkfReport:
"""Validate a vault folder against OKF v0.1 conformance rules 1 & 2.

Rule 3 (reserved-file structure) holds by construction — omind generates
``index.md`` with no frontmatter and ``#`` section headings — so this checks
the per-concept rules: every non-reserved note has a parseable frontmatter
block carrying a non-empty ``type``.
"""
folder = Path(omi_dir).expanduser()
report = OkfReport()
for path in sorted(folder.glob("*.md")):
if path.name in RESERVED_OKF_FILES:
continue
report.concepts += 1
text = path.read_text(encoding="utf-8-sig", errors="replace")
block = _leading_frontmatter_block(text)
if not block:
report.problems.append(OkfProblem(path.name, "no parseable YAML frontmatter block"))
continue
fm = parse_frontmatter(block)
if not fm:
report.problems.append(
OkfProblem(path.name, "frontmatter is not a parseable YAML mapping")
)
continue
if not str(fm.get("type", "") or "").strip():
report.problems.append(OkfProblem(path.name, "frontmatter has no non-empty 'type'"))
continue
report.conformant += 1
return report


@dataclass
class ConvertResult:
"""Outcome of :func:`convert_vault`: how many notes changed, plus the scan."""

converted: int = 0
unchanged: int = 0
report: OkfReport = field(default_factory=OkfReport)


def convert_vault(omi_dir: Path | str, *, dry_run: bool = False) -> ConvertResult:
"""Rewrite every note in ``omi_dir`` into OKF form (frontmatter + ``type``).

Idempotent: a note already in OKF form renders byte-identical and is skipped
(no write, no mesh revision bump), so re-running is a no-op. The conformance
scan runs afterwards and rides back on the result.

Run this on ONE node and let the mesh replicate the reformatted notes: each
rewrite bumps the note's Lamport revision, so converting the same vault
concurrently on two peers would create needless merge work.
"""
store = OmiStore(omi_dir)
result = ConvertResult()
for summary in store.list_notes(include_disabled=True):
name = summary.filename
raw = store.read_note(name)
rendered = render_fields(parse_note(raw))
if rendered.strip() == raw.strip():
result.unchanged += 1
continue
if not dry_run:
store.write_note(name, rendered)
result.converted += 1
result.report = check_conformance(store.omi_dir)
return result
Loading
Loading