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
6 changes: 5 additions & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ five places their design is genuinely better and the idea transfers._
its own docstring ("a process crash can still leave extra recoverable copies").
Generalize what `omind consolidate` already prototypes into a store-level
primitive plus `omind recover`. Skip their `approved_plan_sha256` handshake.
- [ ] **Typed confidence + symmetric `Conflicts with:` provenance** ([#195](https://github.com/CryptoJones/omind/issues/195)) — _enhancement (memory shape)_ —
- [x] **Typed confidence + symmetric `Conflicts with:` provenance** ([#195](https://github.com/CryptoJones/omind/issues/195)) — _enhancement (memory shape)_ —
shipped as two optional note fields that round-trip through Markdown, CLI, MCP, and
mesh merge like `Supersedes:`. A conflict binds both notes even when one side declared
it; retrieval surfaces the disagreement rather than resolving it; lint flags broken and
one-sided claims. No research-grade ledger. Original description follows.
their claim ledger types authority, assessment, confidence, and evidence relation
(`supports`/`contradicts`/`context`), keeping contradictions visible. omind's
`references:` is free text and `Supersedes:` only expresses clean ordered
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,47 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [6.6.0] - 2026-08-02

### Added

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a blank line after each heading.

markdownlint-cli2 reports MD022 because ### Added and ### Changed have no blank line below them. Add one blank line after each heading.

Proposed fix
 ### Added
+
 - **Two optional provenance fields on a note: `Confidence:` and

 ### Changed
+
 - Ranking: `Confidence: low` applies a gentle 0.8 penalty, so a comparable

Also applies to: 40-40

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 10-10: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 10, Add a blank line immediately after each changelog
heading, including “Added” and “Changed”, to satisfy markdownlint MD022 while
preserving the existing entries.

Source: Linters/SAST tools

- **Two optional provenance fields on a note: `Confidence:` and
`Conflicts with:`** ([#195](https://github.com/CryptoJones/omind/issues/195)).
Until now a note could express exactly one provenance fact — "this replaced
that", via `Supersedes:`. That is a *clean ordered replacement*. Real recall
failures are messier: two notes that disagree, neither reviewed, and the agent
trusts whichever one the ranker happened to surface first. There was no way to
say "these disagree" or "this was never verified".

- `Confidence: high|medium|low` — absent means unknown, which is every note
that already exists. An unrecognised value is dropped rather than raising:
it arrives from hand-edited Markdown and from mesh peers running older code,
and a typo in one note must never make that note unreadable.
- `Conflicts with: [[Other]]` — **symmetric in effect even when only one side
declares it.** The point of the field is that the agent sees the
disagreement, so whichever note retrieval surfaces carries the warning.

Both round-trip through Markdown, the CLI (`omind note --confidence`,
`--conflicts-with`), MCP (`create-note`, `edit-note`), and the mesh merge
driver, exactly as `Supersedes:` does. A partial edit no longer clears them.

- `recall-note` returns `confidence`, `conflicts_with`, and a `warning` naming
the other note — emitted **only** when the note declares them, so a note
without these fields costs the same tokens it did before. `search-vault` hits
carry both fields.

- `omind lint` reports `conflict-broken` (a conflict pointing at no note) and
`conflict-one-sided` (the other side never acknowledged it) — retrieval treats
a one-sided claim as binding on both notes, so lint says so out loud.

### Changed
- Ranking: `Confidence: low` applies a gentle 0.8 penalty, so a comparable
verified note wins a tie. Deliberately nothing like the 0.35 superseded
penalty — low confidence is not obsolescence, and a hedged memory is still
worth recalling. Conflicts do **not** affect ranking at all: the point is to
show the disagreement, not to pick a winner.
- Search index schema → v5 (the two new columns). The index rebuilds itself
automatically on first use; no action needed.

## [6.5.0] - 2026-08-02

### Added
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,12 @@ 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), kept alongside the frontmatter so existing tooling and
un-upgraded mesh peers keep reading it unchanged;
- a `# Title` and a `## Metadata` block (created date, `#tags`, the mesh
`Rev:` Lamport stamp, and optional provenance — `Supersedes:` /
`Superseded by:`, `Confidence:` (`high`/`medium`/`low`), and `Conflicts with:`
for two memories that *disagree* rather than one replacing the other), 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);
Expand Down
10 changes: 10 additions & 0 deletions docs/retrieval.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ Two rules keep the result honest:
- **Superseded facts remain history, not current truth.** A note carrying
`Superseded by:`—or targeted by another note's `Supersedes:` metadata—stays
searchable but receives a strong ranking penalty.
- **Self-declared low confidence loses a tie, not the race.** `Confidence: low`
applies a gentle penalty (0.8) so a comparable verified note wins. Low
confidence is not obsolescence, so it is nothing like the superseded penalty.
- **Conflicts are surfaced, never resolved.** When a note carries
`Conflicts with: [[Other]]`, *both* notes come back marked with the other's
name — the claim is symmetric even when only one side wrote it down. Ranking
does not pick a winner: the agent is told the two memories disagree and can
read both. `omind lint` reports a conflict whose target does not exist
(`conflict-broken`) and one the other side never acknowledged
(`conflict-one-sided`).

The keyword leg is graded: chunks matching *every* word of your query rank above
chunks matching only some. That keeps a filler word ("how do I **handle**…")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "omind"
version = "6.5.0"
version = "6.6.0"
description = "Reproduce the OMI/Obsidian memory integration for AI agents, plus a local web app to view, edit, and add memory entries."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/omind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Copyright 2026 Aaron K. Clark
"""omind — OMI/Obsidian memory tooling for AI agents."""

__version__ = "6.5.0"
__version__ = "6.6.0"
13 changes: 13 additions & 0 deletions src/omind/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,17 @@ def build_parser() -> argparse.ArgumentParser:
note.add_argument("--related-to", default="", help="free-text 'related to' line")
note.add_argument("--supersedes", default="", help="older note this fact supersedes")
note.add_argument("--superseded-by", default="", help="newer note that supersedes this fact")
note.add_argument(
"--confidence",
default="",
choices=("", "high", "medium", "low"),
help="how well established this memory is (default: unstated)",
)
note.add_argument(
"--conflicts-with",
default="",
help="a note this memory DISAGREES with (use --supersedes when it cleanly replaces it)",
)
note.add_argument("--connections", default="", help="comma-separated note titles to [[link]]")
note.add_argument(
"--connection",
Expand Down Expand Up @@ -1317,6 +1328,8 @@ def _run_note(args: argparse.Namespace) -> int:
tags=_split_csv(args.tags),
related_to=args.related_to.strip(),
supersedes=args.supersedes.strip(),
confidence=args.confidence.strip(),
conflicts_with=args.conflicts_with.strip(),
superseded_by=args.superseded_by.strip(),
# CSV titles plus any repeatable --connection (exact titles, comma-safe).
connections=(
Expand Down
47 changes: 46 additions & 1 deletion src/omind/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
class LintIssue:
"""One problem found in the vault."""

kind: str # broken-link | missing-title | isolated | near-duplicate
kind: str # broken-link | missing-title | isolated | near-duplicate | conflict-*
severity: str # error | warn | info
note: str # the offending note's filename (or "A | B" for a pair)
detail: str
Expand Down Expand Up @@ -190,6 +190,7 @@ def _load_indexed(omi_dir: Path | str) -> tuple[list[_Note], set[str], Any] | No
fields = NoteFields(
title=row.title if row.has_title else "",
disabled=row.disabled,
conflicts_with=row.conflicts_with,
)
path = omi / row.filename
ids = _note_ids(path, fields)
Expand Down Expand Up @@ -244,6 +245,50 @@ def lint_vault(omi_dir: Path | str) -> list[LintIssue]:
LintIssue("isolated", "info", n.path.name, "no inbound or outbound links")
)

# `Conflicts with:` is symmetric in meaning but written on one note at a
# time, so the two ways it goes wrong are a target that does not exist and
# a claim the other side never acknowledged. Retrieval treats a one-sided
# claim as binding on both notes; lint says so out loud.
declared: dict[str, str] = {}
ids_to_note: dict[str, str] = {}
for n in notes:
for note_id in n.ids:
ids_to_note[note_id] = n.path.name
for n in notes:
claim = n.fields.conflicts_with.strip()
if not claim:
continue
target = claim.strip("[]").split("|", 1)[0].split("#", 1)[0].strip().lower()
if target in n.ids:
issues.append(
LintIssue(
"conflict-self", "warn", n.path.name, "`Conflicts with:` points at itself"
)
)
continue
if target not in known:
issues.append(
LintIssue(
"conflict-broken",
"error",
n.path.name,
f"`Conflicts with: [[{claim}]]` resolves to no note",
)
)
continue
if resolved := ids_to_note.get(target):
declared[n.path.name] = resolved
Comment on lines +248 to +280

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate reciprocity for every valid conflict target.

known includes archived and subfolder note IDs through known_extra, but ids_to_note contains only active top-level notes. If an active note conflicts with an archived or subfolder note, the target is valid, but ids_to_note.get(target) returns nothing. The code then emits neither conflict-broken nor conflict-one-sided, even when the target has no reciprocal declaration.

Load conflict metadata for valid non-source targets into the reciprocal lookup. Keep those notes excluded as lint sources. Add a regression test for this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/omind/lint.py` around lines 248 - 280, Update the conflict validation
around ids_to_note and the valid-target branch so every target recognized by
known, including archived and subfolder notes from known_extra, is loaded into
the reciprocal lookup while remaining excluded from the outer lint-source
iteration. Ensure valid targets without reciprocal declarations can reach the
existing conflict-one-sided validation, and add a regression test covering an
active note targeting an archived or subfolder note.

for source, target_name in sorted(declared.items()):
if declared.get(target_name) != source:
issues.append(
LintIssue(
"conflict-one-sided",
"info",
source,
f"{target_name} does not declare the conflict back",
)
)

# Semantic duplicate candidates from the index; title-Jaccard is the
# fail-open path when the optional embedding backend is absent.
semantic = index.duplicate_pairs() if index is not None else None
Expand Down
2 changes: 2 additions & 0 deletions src/omind/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ def scalar(name: str) -> str | bool:
related_to=str(scalar("related_to")),
supersedes=str(scalar("supersedes")),
superseded_by=str(scalar("superseded_by")),
confidence=str(scalar("confidence")),
conflicts_with=str(scalar("conflicts_with")),
connections=_union3(base.connections, ours.connections, theirs.connections),
action_items=_merge_actions(base.action_items, ours.action_items, theirs.action_items),
references=_union3(base.references, ours.references, theirs.references),
Expand Down
15 changes: 14 additions & 1 deletion src/omind/recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def compact_recall(
if truncated:
marker = "\n…[truncated; request a section or a larger max_chars value]"
content = content[: max(0, limit - len(marker))].rstrip() + marker
return {
payload: dict[str, Any] = {
"filename": filename,
"title": fields.title or Path(filename).stem,
"summary": fields.summary,
Expand All @@ -90,6 +90,19 @@ def compact_recall(
"truncated": truncated,
"version": store.note_version(name),
}
# Provenance, emitted only when the note declares it, so a note without
# these fields costs exactly the tokens it did before (#195). The conflict
# is the load-bearing one: without it the agent reads one side of a
# disagreement and has no way to know the other side exists.
if fields.confidence:
payload["confidence"] = fields.confidence
if fields.conflicts_with:
payload["conflicts_with"] = fields.conflicts_with
payload["warning"] = (
f"This memory is recorded as conflicting with {fields.conflicts_with}. "
"Read that note before acting on this one."
)
return payload


def filename_for_title(omi_dir: Path | str, title: str) -> str | None:
Expand Down
Loading