diff --git a/BACKLOG.md b/BACKLOG.md index f6aba49..5f93b5e 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7523e36..0694cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 +- **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 diff --git a/README.md b/README.md index 3527029..7800b24 100644 --- a/README.md +++ b/README.md @@ -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); diff --git a/docs/retrieval.md b/docs/retrieval.md index 3d4a893..4b0e843 100644 --- a/docs/retrieval.md +++ b/docs/retrieval.md @@ -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**…") diff --git a/pyproject.toml b/pyproject.toml index 8103fd1..c3463ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/omind/__init__.py b/src/omind/__init__.py index ad3054c..af2c7b5 100644 --- a/src/omind/__init__.py +++ b/src/omind/__init__.py @@ -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" diff --git a/src/omind/cli.py b/src/omind/cli.py index 5e90bbe..f516d43 100644 --- a/src/omind/cli.py +++ b/src/omind/cli.py @@ -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", @@ -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=( diff --git a/src/omind/lint.py b/src/omind/lint.py index aa17dd5..cb074af 100644 --- a/src/omind/lint.py +++ b/src/omind/lint.py @@ -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 @@ -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) @@ -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 + 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 diff --git a/src/omind/merge.py b/src/omind/merge.py index 21e447d..c2c4f5b 100644 --- a/src/omind/merge.py +++ b/src/omind/merge.py @@ -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), diff --git a/src/omind/recall.py b/src/omind/recall.py index 65ee01b..d0eca4c 100644 --- a/src/omind/recall.py +++ b/src/omind/recall.py @@ -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, @@ -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: diff --git a/src/omind/searchindex.py b/src/omind/searchindex.py index 45f35ad..457d613 100644 --- a/src/omind/searchindex.py +++ b/src/omind/searchindex.py @@ -59,7 +59,7 @@ from omind import paths #: Bumped whenever the schema below changes shape; a mismatch rebuilds from scratch. -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 #: RRF constant. 60 is the value from the original TREC paper and what every @@ -77,6 +77,10 @@ _GENERATED_WEIGHT = 0.75 #: Superseded notes remain searchable history, but should not present as current. _SUPERSEDED_WEIGHT = 0.35 +#: A note that declares ``Confidence: low`` is still a memory worth having; it +#: just should not outrank a comparable one that was actually verified. Much +#: gentler than the superseded penalty — low confidence is not obsolescence. +_LOW_CONFIDENCE_WEIGHT = 0.8 #: Only the fused head pays the extra local embedding pass. _RERANK_DEPTH = 20 #: How many candidates each leg contributes before fusion. @@ -131,6 +135,12 @@ class Hit: keyword_rank: int = 0 vector_rank: int = 0 recency_rank: int = 0 + #: The note this one declares (or is declared to be) in conflict with, if + #: any. Surfaced so an agent sees a disagreement instead of silently + #: trusting whichever side the ranker happened to put first (#195). + conflicts_with: str = "" + #: "high" | "medium" | "low"; "" when the note declares none. + confidence: str = "" @dataclass @@ -188,6 +198,10 @@ class _Weights: owners: dict[int, str] generated: frozenset[int] superseded: frozenset[str] + low_confidence: frozenset[str] + #: filename -> the filename it declares a conflict with, resolved through + #: the same alias map ``superseded`` uses. Symmetric: both sides are keyed. + conflicts: dict[str, str] @dataclass @@ -198,6 +212,8 @@ class _NoteRow: okf_type: str = "" supersedes: str = "" superseded_by: str = "" + confidence: str = "" + conflicts_with: str = "" has_title: bool = True tags: list[str] = field(default_factory=list) disabled: bool = False @@ -214,6 +230,8 @@ class _NoteRow: okf_type TEXT NOT NULL DEFAULT '', supersedes TEXT NOT NULL DEFAULT '', superseded_by TEXT NOT NULL DEFAULT '', + confidence TEXT NOT NULL DEFAULT '', + conflicts_with TEXT NOT NULL DEFAULT '', has_title INTEGER NOT NULL DEFAULT 1, disabled INTEGER NOT NULL DEFAULT 0, mtime_ns INTEGER NOT NULL DEFAULT 0, @@ -674,6 +692,8 @@ def _ingest( okf_type=fields.okf_type.strip() or derive_okf_type(fields.tags), supersedes=fields.supersedes, superseded_by=fields.superseded_by, + confidence=fields.confidence, + conflicts_with=fields.conflicts_with, has_title=bool(fields.title), tags=fields.tags, disabled=fields.disabled, @@ -681,7 +701,8 @@ def _ingest( self._forget(db, path.name) db.execute( "INSERT INTO notes(filename, title, created, okf_type, supersedes, superseded_by," - " has_title, disabled, mtime_ns, size, sha) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + " confidence, conflicts_with, has_title, disabled, mtime_ns, size, sha)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( row.filename, row.title, @@ -689,6 +710,8 @@ def _ingest( row.okf_type, row.supersedes, row.superseded_by, + row.confidence, + row.conflicts_with, int(row.has_title), int(row.disabled), st.st_mtime_ns, @@ -812,6 +835,7 @@ def search( fused = self._rerank(db, query, fused) fused = self._weight_generated(db, fused) fused = self._weight_superseded(db, fused) + fused = self._weight_confidence(db, fused) if not fused: return [] ranks = { @@ -1010,8 +1034,13 @@ def _weighting(self, db: sqlite3.Connection) -> _Weights: aliases: dict[str, str] = {} superseded: set[str] = set() note_rows = list( - db.execute("SELECT filename, title, okf_type, supersedes, superseded_by FROM notes") + db.execute( + "SELECT filename, title, okf_type, supersedes, superseded_by," + " confidence, conflicts_with FROM notes" + ) ) + low_confidence: set[str] = set() + conflict_claims: list[tuple[str, str]] = [] generated_notes: set[str] = set() for row in note_rows: filename = str(row["filename"]) @@ -1020,6 +1049,10 @@ def _weighting(self, db: sqlite3.Connection) -> _Weights: aliases[str(row["title"]).strip().lower()] = filename if str(row["superseded_by"]).strip(): superseded.add(filename) + if str(row["confidence"]).strip().lower() == "low": + low_confidence.add(filename) + if claim := str(row["conflicts_with"]).strip(): + conflict_claims.append((filename, claim)) name = Path(filename).stem.lower() if ( str(row["okf_type"]).strip().lower() @@ -1035,6 +1068,17 @@ def _weighting(self, db: sqlite3.Connection) -> _Weights: clean = target.strip("[]").split("|", 1)[0].split("#", 1)[0].strip().lower() if resolved := aliases.get(clean): superseded.add(resolved) + # A conflict is symmetric even when only one side declares it: the + # point of the field is that the agent sees the disagreement, and + # whichever note the ranker surfaced must carry the warning. + conflicts: dict[str, str] = {} + for filename, claim in conflict_claims: + clean = claim.strip("[]").split("|", 1)[0].split("#", 1)[0].strip().lower() + other = aliases.get(clean) + if other is None or other == filename: + continue # dangling or self-referential; lint reports it + conflicts.setdefault(filename, other) + conflicts.setdefault(other, filename) for row in db.execute("SELECT id, filename FROM chunks"): chunk_id = int(row["id"]) filename = str(row["filename"]) @@ -1045,6 +1089,8 @@ def _weighting(self, db: sqlite3.Connection) -> _Weights: owners=owners, generated=frozenset(generated), superseded=frozenset(superseded), + low_confidence=frozenset(low_confidence), + conflicts=conflicts, ) self._weights = (generation, weights) return weights @@ -1091,6 +1137,28 @@ def _weight_superseded( ] return sorted(weighted, key=lambda item: (-item[1], item[0])) + def _weight_confidence( + self, db: sqlite3.Connection, fused: list[tuple[int, float]] + ) -> list[tuple[int, float]]: + """Nudge self-declared low-confidence notes below verified ones. + + Deliberately gentle: a low-confidence memory is still a memory worth + recalling, it just should not beat a comparable one that was checked. + Unlike superseded, this is not obsolescence. + """ + weights = self._weighting(db) + low, owners = weights.low_confidence, weights.owners + if not low: + return fused + weighted = [ + ( + chunk_id, + score * _LOW_CONFIDENCE_WEIGHT if owners.get(chunk_id) in low else score, + ) + for chunk_id, score in fused + ] + return sorted(weighted, key=lambda item: (-item[1], item[0])) + def _listing( self, db: sqlite3.Connection, allowed: set[str] | None, limit: int ) -> list[Hit]: @@ -1122,11 +1190,13 @@ def _materialize( from omind import retrieve task_is_cred = bool(retrieve._tokens(query) & retrieve._CREDENTIAL_STEMS) + weights = self._weighting(db) hits: list[Hit] = [] seen: set[str] = set() for chunk_id, score in fused: row = db.execute( "SELECT c.filename AS filename, c.heading AS heading, n.title AS title," + " n.confidence AS confidence," " (SELECT group_concat(tag, ' ') FROM note_tags t WHERE t.filename = c.filename)" " AS tags FROM chunks c JOIN notes n ON n.filename = c.filename WHERE c.id = ?", (chunk_id,), @@ -1149,6 +1219,8 @@ def _materialize( db, chunk_id, expr, filename=name, max_chars=excerpt_chars ), score=score, + conflicts_with=weights.conflicts.get(name, ""), + confidence=str(row["confidence"] or ""), keyword_rank=ranks["keyword"].get(chunk_id, 0), vector_rank=ranks["vector"].get(chunk_id, 0), ) @@ -1241,6 +1313,8 @@ def _notes(db: sqlite3.Connection) -> list[_NoteRow]: created=str(r["created"]), okf_type=str(r["okf_type"]), supersedes=str(r["supersedes"]), + confidence=str(r["confidence"]), + conflicts_with=str(r["conflicts_with"]), superseded_by=str(r["superseded_by"]), has_title=bool(r["has_title"]), tags=tags.get(str(r["filename"]), []), @@ -1249,7 +1323,7 @@ def _notes(db: sqlite3.Connection) -> list[_NoteRow]: ) for r in db.execute( "SELECT filename, title, created, okf_type, supersedes, superseded_by," - " has_title, disabled, mtime_ns FROM notes" + " confidence, conflicts_with, has_title, disabled, mtime_ns FROM notes" ) ] diff --git a/src/omind/server.py b/src/omind/server.py index 4c138ad..360dfe1 100644 --- a/src/omind/server.py +++ b/src/omind/server.py @@ -264,7 +264,10 @@ def recall_note( name="create-note", description=( "Create a memory note. Lists: tags (no leading #), connections " - "([[wikilink]] targets), references, action_items ('[x] text' = done)." + "([[wikilink]] targets), references, action_items ('[x] text' = done). " + "confidence: high|medium|low, omit if unknown. conflicts_with: a " + "[[wikilink]] to a memory this one DISAGREES with (use supersedes " + "instead when this cleanly replaces the older fact)." ), ) def create_note( @@ -275,6 +278,8 @@ def create_note( related_to: str = "", supersedes: str = "", superseded_by: str = "", + confidence: str = "", + conflicts_with: str = "", connections: list[str] | None = None, action_items: list[str] | None = None, references: list[str] | None = None, @@ -287,6 +292,8 @@ def create_note( related_to=related_to, supersedes=supersedes, superseded_by=superseded_by, + confidence=confidence, + conflicts_with=conflicts_with, connections=connections or [], action_items=_parse_action_items(action_items or []), references=references or [], @@ -311,6 +318,8 @@ def edit_note( related_to: str | None = None, supersedes: str | None = None, superseded_by: str | None = None, + confidence: str | None = None, + conflicts_with: str | None = None, connections: list[str] | None = None, action_items: list[str] | None = None, references: list[str] | None = None, @@ -331,6 +340,10 @@ def edit_note( fields.supersedes = supersedes if superseded_by is not None: fields.superseded_by = superseded_by + if confidence is not None: + fields.confidence = confidence + if conflicts_with is not None: + fields.conflicts_with = conflicts_with if connections is not None: fields.connections = connections if action_items is not None: diff --git a/src/omind/store.py b/src/omind/store.py index 9c168c7..4b5ee2f 100644 --- a/src/omind/store.py +++ b/src/omind/store.py @@ -180,6 +180,12 @@ class NoteFields: related_to: str = "" supersedes: str = "" superseded_by: str = "" + # Two optional provenance fields (#195). `Supersedes:` can only express a + # clean ordered replacement; real recall failures are messier — two notes + # that disagree, neither reviewed. Absent means "unknown", which is every + # note written before these existed, so absence must stay the default. + confidence: str = "" # "high" | "medium" | "low"; see CONFIDENCE_LEVELS + conflicts_with: str = "" # a [[wikilink]], symmetric like Supersedes connections: list[str] = field(default_factory=list) action_items: list[ActionItem] = field(default_factory=list) references: list[str] = field(default_factory=list) @@ -227,6 +233,8 @@ def from_dict(cls, data: dict[str, Any]) -> NoteFields: related_to=str(data.get("related_to", "")).strip(), supersedes=str(data.get("supersedes", "")).strip(), superseded_by=str(data.get("superseded_by", "")).strip(), + confidence=_clean_confidence(data.get("confidence", "")), + conflicts_with=str(data.get("conflicts_with", "")).strip(), connections=[str(c).strip() for c in (data.get("connections") or []) if str(c).strip()], action_items=items, references=[str(r).strip() for r in (data.get("references") or []) if str(r).strip()], @@ -257,12 +265,34 @@ class NoteSummary: #: so a listing entry costs no more than it did before. excerpt: str = "" score: float = 0.0 + #: Search-only provenance (#195): the note this one is in conflict with + #: (either side may declare it), and its self-declared confidence. Both + #: empty unless the note carries the field, so nothing changes for the + #: notes that don't. + conflicts_with: str = "" + confidence: str = "" def _clean_tag(tag: object) -> str: return str(tag).lstrip("#").strip() +#: The closed vocabulary for a note's ``Confidence:``. Absent — every note +#: written before the field existed — means unknown, and is not an error. +CONFIDENCE_LEVELS = ("high", "medium", "low") + + +def _clean_confidence(value: object) -> str: + """Normalise a confidence to the closed vocabulary, or ``""``. + + Unrecognised values are dropped rather than raising: this arrives from + hand-edited Markdown and from mesh peers running older code, and a typo in + one note must never make the note unreadable. + """ + level = str(value).strip().lower() + return level if level in CONFIDENCE_LEVELS else "" + + def today() -> str: return date.today().isoformat() @@ -465,6 +495,8 @@ def body(name: str) -> str: related_to = "" supersedes = "" superseded_by = "" + confidence = "" + conflicts_with = "" rev = "" disabled = False tags: list[str] = [] @@ -479,6 +511,10 @@ def body(name: str) -> str: supersedes = m.group(1).strip() elif m := re.match(r"^\s*-\s*Superseded by:\s*(.*)$", line): superseded_by = m.group(1).strip() + elif m := re.match(r"^\s*-\s*Confidence:\s*(.*)$", line): + confidence = _clean_confidence(m.group(1)) + elif m := re.match(r"^\s*-\s*Conflicts with:\s*(.*)$", line): + conflicts_with = m.group(1).strip() elif m := _REV_LINE_RE.match(line): rev = m.group(1).strip() elif _DISABLED_LINE_RE.match(line): @@ -528,6 +564,8 @@ def body(name: str) -> str: related_to=related_to, supersedes=supersedes, superseded_by=superseded_by, + confidence=confidence, + conflicts_with=conflicts_with, connections=[c.strip() for c in connections if c.strip()], action_items=action_items, references=references, @@ -566,6 +604,10 @@ def render_fields(f: NoteFields) -> str: out.append(f"- Supersedes: {f.supersedes}") if f.superseded_by: out.append(f"- Superseded by: {f.superseded_by}") + if f.confidence: + out.append(f"- Confidence: {f.confidence}") + if f.conflicts_with: + out.append(f"- Conflicts with: {f.conflicts_with}") if f.rev: out.append(f"- Rev: {f.rev}") if f.disabled: @@ -995,7 +1037,15 @@ def _indexed_search( if summary is None: continue # indexed a note that has since been deleted # replace(), not mutation: ``summary`` is the shared cached instance. - results.append(replace(summary, excerpt=hit.excerpt, score=round(hit.score, 6))) + results.append( + replace( + summary, + excerpt=hit.excerpt, + score=round(hit.score, 6), + conflicts_with=hit.conflicts_with, + confidence=hit.confidence, + ) + ) return results def _scan_search( @@ -1303,6 +1353,13 @@ def transform(text: str) -> str: fields.supersedes = current.supersedes if not fields.superseded_by: fields.superseded_by = current.superseded_by + # Same inheritance for the provenance fields (#195): a partial edit + # that carries neither must not silently clear a note's confidence + # or drop a recorded conflict. + if not fields.confidence: + fields.confidence = current.confidence + if not fields.conflicts_with: + fields.conflicts_with = current.conflicts_with # A multi-section body supplied through `details` (the only such # field the MCP/CLI API exposes) carries ## H2s that read back as # extras. Hoist them now so they REPLACE the same-named inherited diff --git a/tests/test_lint.py b/tests/test_lint.py index 4ce20e7..f9f814b 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -8,6 +8,7 @@ from omind import lint from omind.cli import main +from omind.store import NoteFields, OmiStore def _omi(tmp_path: Path) -> Path: @@ -187,3 +188,29 @@ def test_wikilink_inside_code_fence_is_not_a_link( ) broken = [i for i in lint.lint_vault(omi) if i.kind == "broken-link"] assert broken == [] + + +def test_lint_flags_a_conflict_pointing_at_nothing(tmp_path: Path) -> None: + store = OmiStore(_omi(tmp_path)) + store.create_note(NoteFields(title="A", summary="s", conflicts_with="[[Ghost]]")) + codes = {i.kind for i in lint.lint_vault(_omi(tmp_path))} + assert "conflict-broken" in codes + + +def test_lint_flags_a_one_sided_conflict(tmp_path: Path) -> None: + """Retrieval binds both notes; lint says so when only one declared it.""" + store = OmiStore(_omi(tmp_path)) + store.create_note(NoteFields(title="A", summary="s", conflicts_with="[[B]]")) + store.create_note(NoteFields(title="B", summary="s")) + issues = {i.kind: i for i in lint.lint_vault(_omi(tmp_path))} + assert "conflict-one-sided" in issues + assert "B.md" in issues["conflict-one-sided"].detail + + +def test_lint_accepts_a_mutual_conflict(tmp_path: Path) -> None: + store = OmiStore(_omi(tmp_path)) + store.create_note(NoteFields(title="A", summary="s", conflicts_with="[[B]]")) + store.create_note(NoteFields(title="B", summary="s", conflicts_with="[[A]]")) + codes = {i.kind for i in lint.lint_vault(_omi(tmp_path))} + assert "conflict-one-sided" not in codes + assert "conflict-broken" not in codes diff --git a/tests/test_searchindex.py b/tests/test_searchindex.py index c6c4f1a..3921878 100644 --- a/tests/test_searchindex.py +++ b/tests/test_searchindex.py @@ -471,3 +471,39 @@ def test_stats_reports_what_is_indexed(omi: Path) -> None: stats = idx.stats() assert stats is not None assert stats["notes"] == 1 and int(str(stats["chunks"])) >= 1 + + +def test_low_confidence_ranks_below_a_verified_note(omi: Path) -> None: + detail = "zebracorn cluster failover procedure" + _note(omi, "Verified", "checked", ["ops"], details=detail) + hedged = _note(omi, "Hedged", "unchecked", ["ops"], details=detail) + hedged.write_text( + hedged.read_text(encoding="utf-8").replace( + "- Tags: #ops", "- Tags: #ops\n- Confidence: low" + ), + encoding="utf-8", + ) + hits = searchindex.SearchIndex(omi).search("zebracorn failover") or [] + by_name = {hit.filename: hit for hit in hits} + assert set(by_name) == {"Verified.md", "Hedged.md"} + assert by_name["Verified.md"].score > by_name["Hedged.md"].score + # Still recalled — low confidence is not obsolescence. + assert by_name["Hedged.md"].score > 0 + assert by_name["Hedged.md"].confidence == "low" + + +def test_a_conflict_is_surfaced_on_both_notes(omi: Path) -> None: + """Only one side declares it; both sides must carry the warning (#195).""" + detail = "zebracorn gpu model" + a = _note(omi, "Claim A", "says 1060", ["hw"], details=detail) + _note(omi, "Claim B", "says V620", ["hw"], details=detail) + a.write_text( + a.read_text(encoding="utf-8").replace( + "- Tags: #hw", "- Tags: #hw\n- Conflicts with: [[Claim B]]" + ), + encoding="utf-8", + ) + hits = searchindex.SearchIndex(omi).search("zebracorn gpu") or [] + by_name = {hit.filename: hit for hit in hits} + assert by_name["Claim A.md"].conflicts_with == "Claim B.md" + assert by_name["Claim B.md"].conflicts_with == "Claim A.md" # symmetric diff --git a/tests/test_server.py b/tests/test_server.py index 60f72aa..a052c04 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -433,3 +433,27 @@ def recv() -> dict[str, Any]: proc.wait() if proc.stderr is not None: proc.stderr.close() + + +def test_recall_note_warns_about_a_conflicting_memory(server: MCPServer) -> None: + """The agent must see the disagreement, not just one side of it (#195).""" + call(server, "create-note", {"title": "Older", "summary": "the box has a 1060"}) + call( + server, + "create-note", + { + "title": "Newer", + "summary": "the box has a V620", + "confidence": "low", + "conflicts_with": "[[Older]]", + }, + ) + recalled = call(server, "recall-note", {"name": "Newer.md"}) + assert recalled["conflicts_with"] == "[[Older]]" + assert recalled["confidence"] == "low" + assert "Older" in str(recalled["warning"]) + + # A note with neither field pays nothing for them. + plain = call(server, "recall-note", {"name": "Older.md"}) + assert "conflicts_with" not in plain and "confidence" not in plain + assert "warning" not in plain diff --git a/tests/test_store.py b/tests/test_store.py index 9b7ddda..d663c04 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -825,3 +825,47 @@ def test_disable_ignores_disabled_bullet_in_details(mesh_store: OmiStore) -> Non assert not mesh_store.read_fields(name).disabled mesh_store.disable_note(name) assert mesh_store.read_fields(name).disabled + + +def test_confidence_and_conflicts_round_trip(tmp_path: Path) -> None: + """The two provenance fields survive render -> parse (#195).""" + store = OmiStore(tmp_path) + store.create_note( + NoteFields( + title="Claim A", + summary="the GPU is a 1060", + confidence="low", + conflicts_with="[[Claim B]]", + ) + ) + fields = store.read_fields("Claim A.md") + assert fields.confidence == "low" + assert fields.conflicts_with == "[[Claim B]]" + assert "- Confidence: low" in store.read_note("Claim A.md") + + +def test_absent_provenance_fields_render_nothing(tmp_path: Path) -> None: + """Every note written before these fields existed must be untouched.""" + store = OmiStore(tmp_path) + store.create_note(NoteFields(title="Plain", summary="s")) + raw = store.read_note("Plain.md") + assert "Confidence:" not in raw + assert "Conflicts with:" not in raw + + +def test_unknown_confidence_is_dropped_not_fatal(tmp_path: Path) -> None: + """A typo arrives from hand-edited Markdown and from older mesh peers.""" + store = OmiStore(tmp_path) + store.create_note(NoteFields(title="Typo", summary="s", confidence="pretty sure")) + assert store.read_fields("Typo.md").confidence == "" + assert store.read_fields("Typo.md").summary == "s" # note still readable + + +def test_partial_edit_does_not_clear_provenance(tmp_path: Path) -> None: + store = OmiStore(tmp_path) + store.create_note( + NoteFields(title="Keep", summary="s", confidence="high", conflicts_with="[[Other]]") + ) + store.update_note("Keep.md", NoteFields(title="Keep", summary="edited")) + fields = store.read_fields("Keep.md") + assert fields.confidence == "high" and fields.conflicts_with == "[[Other]]" diff --git a/uv.lock b/uv.lock index 0415a4b..0c8c9f2 100644 --- a/uv.lock +++ b/uv.lock @@ -2354,7 +2354,7 @@ wheels = [ [[package]] name = "omind" -version = "6.5.0" +version = "6.6.0" source = { editable = "." } dependencies = [ { name = "cryptography" },