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
5 changes: 2 additions & 3 deletions docs/DOMAIN_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,8 @@ which graph and search projections attach.
- `external_id` is the stable external/API identity and must survive ordinary updates and moves.
- `file_path` is the current project-relative storage location. It is unique within a project and
may change when the resource moves.
- `permalink` is the human/agent-facing semantic address for Markdown content. It is
project-scoped, may be absent when permalinks are disabled or inapplicable, and changes only
according to the configured move and permalink policies.
- `permalink` is the required human/agent-facing semantic address for Markdown content. It is
project-scoped and changes only according to the configured move and permalink policies.
- Non-Markdown entities always have `permalink=None`. `external_id` is their stable API identity,
and `file_path` locates the stored resource; a database-only permalink would not round-trip
through the source file.
Expand Down
5 changes: 2 additions & 3 deletions plugins/codex/skills/bm-checkpoint/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,5 @@ $bm-orient "<exact returned resume identifier>"
```

Choose the first non-empty returned value in this order: `permalink`,
`file_path`, then `title`. This preserves a direct resume cursor when the Basic
Memory project has permalinks disabled. Use the returned value verbatim; never
construct or guess a permalink or file path.
`file_path`, then `title`. Use the returned value verbatim; never construct or
guess a permalink or file path.
1 change: 1 addition & 0 deletions src/basic_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def load_config(self) -> BasicMemoryConfig:
"project_modes",
"cloud_projects",
"cloud_mode",
"disable_permalinks",
}
needs_resave = bool(stale_keys & file_data.keys())

Expand Down
7 changes: 1 addition & 6 deletions src/basic_memory/config_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,11 +600,6 @@ def __init__(self, **data: Any) -> None: ...
description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
)

disable_permalinks: bool = Field(
default=False,
description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
)

write_note_overwrite_default: bool = Field(
default=False,
description=(
Expand Down Expand Up @@ -637,7 +632,7 @@ def __init__(self, **data: Any) -> None: ...

ensure_frontmatter_on_sync: bool = Field(
default=True,
description="Ensure markdown files have frontmatter during sync by adding derived title/type/permalink when missing. When combined with disable_permalinks=True, this setting takes precedence for missing-frontmatter files and still writes permalinks.",
description="Ensure markdown files have complete frontmatter during sync by adding derived title and type when missing. Canonical permalinks are always added.",
)

permalinks_include_project: bool = Field(
Expand Down
1 change: 0 additions & 1 deletion src/basic_memory/deps/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,6 @@ async def get_note_content_mutation_service(
),
write_repositories=accepted_note_repositories,
move_policy=AcceptedNoteMutationMovePolicy(
disable_permalinks=app_config.disable_permalinks,
update_permalinks_on_move=app_config.update_permalinks_on_move,
),
# Local filesystem is the source of truth: reject a create when the
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/index/local_moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ async def plan_moved_file_content(
app_config = self.entity_service.app_config
if app_config is None:
raise RuntimeError("local move content updates require app_config")
if app_config.disable_permalinks or not app_config.update_permalinks_on_move:
if not app_config.update_permalinks_on_move:
return None
Comment thread
phernandez marked this conversation as resolved.
if not self.file_service.is_markdown(moved_file.new_path):
return None
Expand Down
5 changes: 1 addition & 4 deletions src/basic_memory/indexing/accepted_note_mutation_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,10 @@ class AcceptedNoteDeleteMutation:
class AcceptedNoteMutationMovePolicy:
"""Permalink policy for DB-first accepted note moves."""

disable_permalinks: bool
update_permalinks_on_move: bool

def should_update_permalink(self, entity: Entity) -> bool:
return not self.disable_permalinks and (
self.update_permalinks_on_move or entity.permalink is None
)
return self.update_permalinks_on_move or entity.permalink is None


def accepted_note_mutation_utc_now() -> datetime:
Expand Down
75 changes: 41 additions & 34 deletions src/basic_memory/indexing/batch_indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,11 @@ async def index_markdown_file(
if path != file.path and permalink
}
with logfire.span("index.markdown_file.normalize", path=file.path):
prepared = await self._normalize_markdown_file(prepared, reserved_permalinks)
prepared = await self._normalize_markdown_file(
prepared,
reserved_permalinks,
existing_permalink=existing_permalink_by_path.get(file.path),
)
existing_permalink_by_path[file.path] = prepared.markdown.frontmatter.permalink

with logfire.span("index.markdown_file.persist", path=file.path, is_new=new):
Expand Down Expand Up @@ -416,6 +420,7 @@ async def _normalize_markdown_batch(
normalized[path] = await self._normalize_markdown_file(
prepared_markdown[path],
reserved_permalinks,
existing_permalink=existing_permalink_by_path.get(path),
)
existing_permalink_by_path[path] = normalized[path].markdown.frontmatter.permalink
except Exception as exc:
Expand All @@ -428,22 +433,29 @@ async def _normalize_markdown_file(
self,
prepared: _PreparedMarkdownFile,
reserved_permalinks: set[str],
*,
existing_permalink: str | None = None,
) -> _PreparedMarkdownFile:
final_checksum = prepared.final_checksum
final_content = prepared.content
final_permalink = await self._resolve_batch_permalink(prepared, reserved_permalinks)
final_permalink = await self._resolve_batch_permalink(
prepared,
reserved_permalinks,
existing_permalink=existing_permalink,
)

# Trigger: markdown file has no frontmatter and sync enforcement is enabled.
# Why: downstream indexing relies on normalized metadata and stable permalinks.
# Outcome: write derived metadata back through the storage-agnostic writer.
# A "malformed" file matches neither branch: its fenced block is not YAML,
# so no rewrite can know which bytes were metadata; it is indexed as-is.
if prepared.frontmatter_state == "absent" and self.app_config.ensure_frontmatter_on_sync:
frontmatter_updates = {
"title": prepared.markdown.frontmatter.title,
"type": prepared.markdown.frontmatter.type,
"permalink": final_permalink,
}
if prepared.frontmatter_state == "absent":
Comment thread
phernandez marked this conversation as resolved.
Comment thread
phernandez marked this conversation as resolved.
frontmatter_updates = {"permalink": final_permalink}
if self.app_config.ensure_frontmatter_on_sync:
frontmatter_updates.update(
title=prepared.markdown.frontmatter.title,
type=prepared.markdown.frontmatter.type,
)
write_result = await self.file_writer.write_frontmatter(
IndexFrontmatterUpdate(path=prepared.file.path, metadata=frontmatter_updates)
)
Expand All @@ -456,7 +468,6 @@ async def _normalize_markdown_file(
# Outcome: only the permalink field is updated when it actually differs.
elif (
prepared.frontmatter_state == "present"
and not self.app_config.disable_permalinks
and final_permalink != prepared.markdown.frontmatter.permalink
):
prepared.markdown.frontmatter.metadata["permalink"] = final_permalink
Expand All @@ -468,6 +479,10 @@ async def _normalize_markdown_file(
)
final_checksum = write_result.checksum
final_content = write_result.content
elif prepared.frontmatter_state == "malformed":
# A leading non-YAML fence is authored body content (#1451), so it is
# never rewritten. Its indexed Markdown identity is still mandatory.
prepared.markdown.frontmatter.metadata["permalink"] = final_permalink

return _PreparedMarkdownFile(
file=prepared.file,
Expand All @@ -481,20 +496,19 @@ async def _resolve_batch_permalink(
self,
prepared: _PreparedMarkdownFile,
reserved_permalinks: set[str],
) -> str | None:
should_resolve_permalink = (
prepared.frontmatter_state == "absent" and self.app_config.ensure_frontmatter_on_sync
) or (prepared.frontmatter_state == "present" and not self.app_config.disable_permalinks)
if not should_resolve_permalink:
permalink = prepared.markdown.frontmatter.permalink
if permalink:
reserved_permalinks.add(permalink)
return permalink

desired_permalink = await self.entity_service.resolve_permalink(
prepared.file.path,
markdown=prepared.markdown,
skip_conflict_check=True,
*,
existing_permalink: str | None,
) -> str:
# Malformed frontmatter cannot carry canonical identity on disk. Preserve the
# indexed permalink across moves unless the source becomes safely rewriteable.
desired_permalink = (
existing_permalink
if prepared.frontmatter_state == "malformed" and existing_permalink is not None
Comment thread
phernandez marked this conversation as resolved.
else await self.entity_service.resolve_permalink(
prepared.file.path,
markdown=prepared.markdown,
skip_conflict_check=True,
)
)
return self._reserve_batch_permalink(desired_permalink, reserved_permalinks)

Expand Down Expand Up @@ -1052,18 +1066,11 @@ async def _reconcile_persisted_permalink(
prepared: _PreparedMarkdownFile,
entity: Entity,
) -> _PreparedMarkdownFile:
# Trigger: the source file started without frontmatter and sync is configured
# to leave frontmatterless files alone.
# Why: upsert may still assign a DB permalink even when disk content should stay untouched.
# Outcome: skip reconciliation writes that would silently inject frontmatter.
rewrite_allowed = (
self.app_config.ensure_frontmatter_on_sync
if prepared.frontmatter_state == "absent"
else prepared.frontmatter_state == "present"
)
# Malformed frontmatter cannot be rewritten safely because the leading fence may be
# authored body content. Valid and absent frontmatter have already accepted canonical
# permalink management, so conflict suffixes must be reconciled back to the file.
if (
self.app_config.disable_permalinks
or not rewrite_allowed
prepared.frontmatter_state == "malformed"
or entity.permalink is None
or entity.permalink == prepared.markdown.frontmatter.permalink
):
Expand Down
11 changes: 10 additions & 1 deletion src/basic_memory/repository/entity_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,17 @@ async def get_by_file_paths(
Relation.from_id == Entity.id,
Relation.generation == 0,
)
# A pre-v0.24 Markdown row may have a current checksum but no canonical
# permalink. Mask its checksum so the normal scan reads the unchanged file
# once and writes the required identity into both Markdown and indexed state.
legacy_markdown_identity = (Entity.content_type == "text/markdown") & Entity.permalink.is_(
None
)
indexed_checksum = case(
(or_(publication_pending, legacy_relation_pending), None),
(
or_(publication_pending, legacy_relation_pending, legacy_markdown_identity),
None,
),
else_=Entity.checksum,
).label("checksum")
query = select(Entity.file_path, indexed_checksum).where( # pragma: no cover
Expand Down
18 changes: 8 additions & 10 deletions src/basic_memory/services/entity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,7 @@ def _sync_prepared_schema_state(
source_schema.content_type = prepared.entity_fields.content_type
source_schema.entity_metadata = prepared.entity_fields.entity_metadata

if self.app_config and self.app_config.disable_permalinks:
source_schema._permalink = ""
else:
source_schema._permalink = prepared.entity_fields.permalink
source_schema._permalink = prepared.entity_fields.permalink

async def _read_persisted_write_snapshot(
self,
Expand Down Expand Up @@ -687,6 +684,8 @@ async def create_entity_from_markdown(
Uses UPSERT approach to handle permalink/file_path conflicts cleanly.
"""
logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}")
if markdown.frontmatter.permalink is None:
raise ValueError(f"Markdown note is missing a canonical permalink: {file_path}")
model = entity_model_from_markdown(
file_path, markdown, project_id=self.repository.project_id
)
Expand Down Expand Up @@ -764,8 +763,9 @@ def _apply_markdown_entity_fields(

entity.title = markdown.frontmatter.title
entity.note_type = markdown.frontmatter.type
if markdown.frontmatter.permalink is not None:
entity.permalink = markdown.frontmatter.permalink
if markdown.frontmatter.permalink is None:
raise ValueError(f"Markdown note is missing a canonical permalink: {file_path}")
entity.permalink = markdown.frontmatter.permalink
entity.file_path = file_path.as_posix()
entity.content_type = "text/markdown"
entity.created_at = markdown.created
Expand Down Expand Up @@ -1020,10 +1020,8 @@ async def move_entity(
# 6. Prepare database updates
updates = {"file_path": destination_path}

# 7. Update permalink if configured or if entity has null permalink (unless disabled)
if not app_config.disable_permalinks and (
app_config.update_permalinks_on_move or old_permalink is None
):
# 7. Update permalink if configured or repair a legacy null permalink.
if app_config.update_permalinks_on_move or old_permalink is None:
# Generate new permalink from destination path
new_permalink = await self.resolve_permalink(destination_path)

Expand Down
10 changes: 0 additions & 10 deletions src/basic_memory/services/initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,16 +367,6 @@ async def initialize_app(
Args:
app_config: The Basic Memory project configuration
"""
# Trigger: frontmatter enforcement is enabled while permalink generation is disabled
# Why: missing-frontmatter indexing needs canonical permalinks for deterministic output
# Outcome: log startup precedence so behavior is explicit to operators
if app_config.ensure_frontmatter_on_sync and app_config.disable_permalinks:
logger.warning(
"Config precedence: ensure_frontmatter_on_sync=True overrides "
"disable_permalinks=True for markdown files missing frontmatter during indexing; "
"permalinks will be written."
)

# Trigger: cloud/stateless deployment (skip_local_initialization — either
# for_cloud_tenant's skip_initialization_sync or BASIC_MEMORY_CLOUD_MODE).
# Why: cloud manages its own schema and per-tenant projects from the database.
Expand Down
Loading
Loading