Files
roboco/roboco/vault.py
T
d03181ab48 feat(vault): Obsidian vault V2 — janitor, archival, weekly report, KB ingest, Bases + sync runbook (#482)
* feat(vault): V2 — create-seam + drift janitor, archival, weekly org-report, KB ingest, Bases views + sync runbook

Implements the vault V2 canonical spec end to end (the splice guard shipped
separately and is reused at KB-ingest time):

- materialize-on-create: TaskService.create writes each task's note best-effort
  from the moment it exists; the transition-touch stops no-oping on live work
- drift janitor (services/vault_janitor.py + hourly _vault_janitor_loop): daily
  changed-task re-projection, random drift sample, archival pass — restart-proof
  via RoboCo/_meta/.janitor_state.json, 200/cycle caps, per-item isolation,
  processed-only resume markers, self-repairing state file
- archival: vault_archive_days (30, 0=off) moves old terminal tasks' notes to
  RoboCo/Archive/<year>/Tasks/<project>/ — one write_task code path for janitor
  and rebuild, id8 lookup across Tasks/+Archive/, alias links keep moves safe
- weekly org-report: VaultWriter.write_org_report renders Reports/<ISO-week>.md
  from MetricsService/UsageService (numbers duplicated into frontmatter for
  trend queries), once per ISO week, with a best-effort CEO notification
- KB ingest: IndexType.VAULT_NOTES + VaultNotesIndexPlugin + _vault_kb_loop
  embed the CEO's RoboCo/Notes into the RAG corpus — injection guard as a hard
  gate (flagged notes quarantined with an idempotent callout), traversal- and
  symlink-contained at both config and engine layers, content-hash dedup,
  50-ingest/cycle cap, frontmatter stripped; reaches roboco_kb_search, the
  mentor default domain, claim-time briefings (kind vault_note), and the panel
  KB browser; no migration (chunks table auto-creates; migration 030's
  CHUNK_TABLES tuple appended per the chunks_playbooks precedent)
- Bases views (Task Board.base, Reports.base — schema verified against the
  Obsidian docs) + the Mac sync runbook vault asset
- config/flags/compose: vault_archive_days, vault_report_enabled (flags card),
  vault_kb_enabled (flags card; NAS compose arms it, registry ships it off),
  vault_kb_dirs (+ overlap/traversal validator), vault_kb_interval_seconds
- e2e smoke (tests/e2e_smoke/test_vault_v2.py): real create-seam, real janitor
  cycle incl. archival + state, real KB engine + real guard

* docs: vault V2 sweep — map, RAG corpus, CLAUDE.md

- docs/map/vault.md: V1+V2 — janitor/archival/report/KB data flows, new files,
  config, health posture
- docs/map/orchestrator.md + task-service.md: the two new loops, the create
  seam, the three janitor queries
- docs/rag/architecture/obsidian-vault.md: agent-facing what-changed (notes
  from creation, archive link-safety, CEO notes retrievable, weekly report)
- docs/rag/architecture/config-reference.md: the five new settings
- CLAUDE.md: vault paragraph covers V1+V2; flags-card list mentions the vault
  report/KB flags

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-11 15:51:19 +02:00

230 lines
8.6 KiB
Python

"""``python -m roboco.vault {rebuild|relocate <new-path>}``.
``rebuild``: full re-projection of every live entity (agents, tasks, journal
entries, A2A threads) from the DB into the vault, plus materializing the
shipped ``.obsidian/`` config + ``RoboCo/_meta/`` dashboards from packaged
templates (``roboco/vault_assets/``) if not already present. A task's
``## Narrative`` (Auditor-authored, not derivable from DB state) is read back
from the existing note and preserved across the rebuild. Archive-aware: an
old terminal task projects straight into ``RoboCo/Archive/<year>/`` (same
``VaultWriter.write_task`` path the drift janitor's archival pass uses).
``relocate <new-path>``: move the vault tree to a new location. Notes use
relative/alias-based wikilinks, so nothing inside them needs rewriting. An
already-existing destination (a personal vault) receives only the ``RoboCo/``
subtree plus any absent shipped assets — its own ``.obsidian`` is never
touched.
Both are inert unless ``ROBOCO_OBSIDIAN_VAULT_ENABLED`` is on (rebuild would
otherwise materialize a vault nobody reads).
"""
from __future__ import annotations
import argparse
import asyncio
import shutil
import sys
from datetime import UTC, datetime
from importlib import resources
from pathlib import Path
from typing import Any
from roboco.config import settings
def ensure_vault_assets(vault_root: Path) -> None:
"""Materialize ``.obsidian/`` + ``RoboCo/_meta/`` from packaged templates.
Never overwrites a file that already exists — an operator's own edits to
the shipped config/dashboards survive both a later rebuild and repeated
startup calls (idempotent, cheap when everything's already there).
"""
assets = resources.files("roboco.vault_assets")
_copy_tree(assets.joinpath("obsidian"), vault_root / ".obsidian")
_copy_tree(assets.joinpath("meta"), vault_root / "RoboCo" / "_meta")
def _copy_tree(src: Any, dest: Path) -> None:
for entry in src.iterdir():
target = dest / entry.name
if entry.is_dir():
_copy_tree(entry, target)
elif not target.exists():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(entry.read_bytes())
async def _rebuild_agents(writer: Any, agent_service: Any) -> list[Any]:
from roboco.services.vault_writer import AgentNoteData
agents = await agent_service.list_agents()
for agent in agents:
writer.write_agent(
AgentNoteData(
slug=agent.slug,
name=agent.name,
role=str(getattr(agent.role, "value", agent.role)),
team=str(agent.team.value) if agent.team else None,
)
)
return list(agents)
async def _rebuild_tasks(writer: Any, task_service: Any, project_service: Any) -> None:
from roboco.services.vault_assembly import reproject_task
offset = 0
while True:
tasks = await task_service.list_all(limit=100, offset=offset)
if not tasks:
break
for task in tasks:
await reproject_task(writer, task_service, project_service, task)
offset += len(tasks)
async def _rebuild_journals(
writer: Any, journal_service: Any, agents: list[Any]
) -> None:
from roboco.foundation.policy.journaling import TYPE_TO_SCOPE
from roboco.models.journal import ListEntriesFilter
from roboco.services.vault_writer import JournalNoteData, TaskLinkRef
for agent in agents:
journal = await journal_service.get_or_create_journal(agent.id)
offset = 0
while True:
entries = await journal_service.list_entries(
journal.id, ListEntriesFilter(limit=100, offset=offset)
)
if not entries:
break
for entry in entries:
scope = TYPE_TO_SCOPE.get(entry.type)
task_ref = TaskLinkRef(id=str(entry.task_id)) if entry.task_id else None
writer.write_journal_entry(
JournalNoteData(
entry_id=str(entry.id),
agent_slug=agent.slug,
scope=scope.value if scope else str(entry.type),
title=entry.title,
content=entry.content,
timestamp=entry.timestamp,
task_ref=task_ref,
)
)
offset += len(entries)
async def _rebuild_a2a(writer: Any, db: Any) -> None:
from sqlalchemy import select
from roboco.db.tables import A2AConversationTable
from roboco.services.a2a import A2AService
from roboco.services.vault_writer import A2AMessageData, TaskLinkRef
a2a_service = A2AService(db)
conv_rows = (await db.execute(select(A2AConversationTable))).scalars().all()
epoch = datetime.min.replace(tzinfo=UTC)
for conv in conv_rows:
messages = await a2a_service.get_messages(conv.id, conv.agent_a, limit=500)
task_ref = TaskLinkRef(id=str(conv.task_id)) if conv.task_id else None
for msg in sorted(messages, key=lambda m: m.created_at or epoch):
writer.append_a2a_message(
A2AMessageData(
conversation_id=str(conv.id),
message_id=str(msg.id),
from_agent=msg.from_agent,
to_agent=(
conv.agent_b if msg.from_agent == conv.agent_a else conv.agent_a
),
content=msg.content,
timestamp=msg.created_at or epoch,
task_ref=task_ref,
)
)
async def _rebuild(vault_root: Path) -> None:
from roboco.db.base import get_db_context
from roboco.services.agent import AgentService
from roboco.services.journal import JournalService
from roboco.services.project import get_project_service
from roboco.services.task import TaskService
from roboco.services.vault_writer import VaultWriter
writer = VaultWriter(vault_root)
async with get_db_context() as db:
agent_service = AgentService(db)
task_service = TaskService(db)
journal_service = JournalService(db)
project_service = get_project_service(db)
agents = await _rebuild_agents(writer, agent_service)
await _rebuild_tasks(writer, task_service, project_service)
await _rebuild_journals(writer, journal_service, agents)
await _rebuild_a2a(writer, db)
ensure_vault_assets(vault_root)
def _relocate(new_path: Path) -> int:
"""Move the vault to ``new_path``; 0 on success, 1 on refusal.
An EXISTING destination is a personal vault: graft only the ``RoboCo/``
subtree into it (``shutil.move`` of the whole root would nest the old
dirname inside it) and materialize the shipped ``.obsidian``/``_meta``
assets only where absent — never clobbering the vault's own config. An
absent destination gets the whole-tree move.
"""
old_root = Path(settings.vault_path)
if not old_root.exists() or old_root == new_path:
new_path.mkdir(parents=True, exist_ok=True)
elif new_path.exists():
dest_tree = new_path / "RoboCo"
if dest_tree.exists():
print(
f"Refusing to relocate: {dest_tree} already exists. Move or "
"remove it first.",
file=sys.stderr,
)
return 1
old_tree = old_root / "RoboCo"
if old_tree.exists():
shutil.move(str(old_tree), str(dest_tree))
ensure_vault_assets(new_path)
else:
new_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_root), str(new_path))
print(
f"Vault moved to {new_path}. Set ROBOCO_VAULT_PATH={new_path} in the "
"environment for this to persist across restarts."
)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m roboco.vault")
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser("rebuild", help="full re-projection from the DB")
relocate = subcommands.add_parser("relocate", help="move the vault tree")
relocate.add_argument("new_path", type=Path)
args = parser.parse_args(argv)
if not settings.obsidian_vault_enabled:
print(
"ROBOCO_OBSIDIAN_VAULT_ENABLED is off — nothing to do.",
file=sys.stderr,
)
return 1
if args.command == "rebuild":
asyncio.run(_rebuild(Path(settings.vault_path)))
print(f"Vault rebuilt at {settings.vault_path}")
return 0
return _relocate(args.new_path)
if __name__ == "__main__":
raise SystemExit(main())