Files
roboco/roboco/services/vault_intake_engine.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

350 lines
14 KiB
Python

"""VaultIntakeEngine — vault notes tagged ``#roboco`` become board-review drafts.
The vexa-inspired input loop (V1 item 4). Detection/dedup mirrors the
XEngine mentions poll; the draft itself takes the intake **board-review
path** (exactly what a chat-confirmed "Board review & Start" draft is), not
the held-artifact pattern:
* **Default OFF.** Both ``obsidian_vault_enabled`` AND ``vault_intake_enabled``
must be on — the engine is inert if either is off.
* **Never starts work.** Every draft is a PENDING, Product-Owner-assigned,
``team=board`` task (``source=vault_note``): the PM dispatcher routes it
to the two-reviewer board review (PO + Head of Marketing), which ends in a
CEO notification — nothing enters delivery until the CEO's explicit
``approve_and_start`` hands it to the Main PM. Board dispatch only ever
spawns the advisory REVIEW; board roles have no verb to claim, plan, or
delegate, so the CEO gate is structural.
* **Local model only.** Extraction runs on the local LLM (MemoryDistiller
posture) with a deterministic fallback (first heading / raw body /
checkbox lines) on any failure — never a cloud LLM in the hot path. The
note body is screened (``foundation.policy.injection_guard.
screen_external_text``, the same guard x_engine applies to mention text)
before it reaches the prompt or the fallback extraction, so neither the
local model nor the eventual board/CEO-facing draft carries raw
unscreened text — the board + CEO gates downstream remain the
containment for anything the screen doesn't catch.
* **Dedup ledger.** ``vault_seen_notes`` keys on (vault-relative path,
content hash) so an unchanged note is never reprocessed, but an edited one
is eligible again. The hash excludes RoboCo's own feedback callout so
appending it after processing does not itself trigger a reprocess.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import httpx
from sqlalchemy import select
from roboco.config import settings
from roboco.db.tables import VaultSeenNoteTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.injection_guard import screen_external_text
from roboco.foundation.policy.vault_notes import content_hash as _content_hash
from roboco.foundation.policy.vault_notes import split_frontmatter as _split_frontmatter
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.services.base import BaseService
from roboco.services.project import get_project_service
from roboco.services.task import VAULT_NOTE_SOURCE, TaskCreateRequest, get_task_service
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable, TaskTable
_CHAT_TIMEOUT_SECONDS = 60.0
_AC_MAX_ITEMS = 7
_AC_MAX_ITEM_CHARS = 200
_TITLE_MAX_CHARS = 200
_DEFAULT_AC = "CEO reviews and starts this drafted task"
# A whole `#roboco` tag, not a prefix of a longer tag (`#roboco/idea`) or word.
_INLINE_TAG_RE = re.compile(r"(?<![\w/-])#roboco(?![\w/-])")
_HEADING_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
_CHECKBOX_RE = re.compile(r"^\s*-\s*\[ \]\s*(.+?)\s*$", re.MULTILINE)
def _has_roboco_tag(frontmatter: dict[str, Any], body: str) -> bool:
"""True if frontmatter ``tags`` (list or scalar) or the body carries a
whole ``#roboco`` tag."""
tags = frontmatter.get("tags")
if isinstance(tags, str):
tags = [tags]
if isinstance(tags, list) and any(
str(t).strip().lstrip("#") == "roboco" for t in tags
):
return True
return bool(_INLINE_TAG_RE.search(body))
def _clamp_action_items(items: list[str]) -> list[str]:
cleaned = [i.strip()[:_AC_MAX_ITEM_CHARS] for i in items if i.strip()]
return cleaned[:_AC_MAX_ITEMS]
@dataclass(frozen=True)
class _NoteExtraction:
"""title + description + action items pulled from one vault note —
bundled so ``_originate`` doesn't need one param per field."""
title: str
description: str
action_items: list[str]
def _deterministic_extract(body: str, fallback_title: str) -> _NoteExtraction:
"""Local-model-failure fallback: first heading, raw body, checkbox lines."""
heading = _HEADING_RE.search(body)
title = heading.group(1).strip() if heading else fallback_title
action_items = [m.group(1).strip() for m in _CHECKBOX_RE.finditer(body)]
description = body.strip() or f"Vault note: {title}"
return _NoteExtraction(title, description, action_items)
def _extraction_prompt(body: str) -> str:
return (
"Extract a task draft from this Obsidian vault note. Reply with ONLY "
'a JSON object: {"title": <short title>, "description": <1-3 '
'sentence summary>, "action_items": [<action item>, ...]}. No '
"markdown fences, no commentary.\n\n"
f"Note:\n{body.strip()}\n"
)
async def _chat(prompt: str) -> str | None:
"""One local-LLM chat call (OpenAI-compatible); None on a non-success."""
async with httpx.AsyncClient(timeout=_CHAT_TIMEOUT_SECONDS) as client:
resp = await client.post(
f"{settings.local_llm_base_url}/chat/completions",
json={
"model": settings.local_llm_model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 400,
},
)
if not resp.is_success:
return None
data = resp.json()
choices = data.get("choices") or []
if not choices:
return None
content = choices[0].get("message", {}).get("content")
return content if isinstance(content, str) else None
def _parse_extraction(raw: str) -> _NoteExtraction | None:
"""Parse the local model's JSON reply; None on any malformed shape."""
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").removeprefix("json").strip()
try:
data = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(data, dict):
return None
title = str(data.get("title") or "").strip()
description = str(data.get("description") or "").strip()
if not title or not description:
return None
raw_items = data.get("action_items")
items = [str(i) for i in raw_items] if isinstance(raw_items, list) else []
return _NoteExtraction(title, description, items)
class VaultIntakeEngine(BaseService):
"""Turn ``#roboco``-tagged vault notes into board-review intake drafts."""
service_name = "vault_intake_engine"
async def run_cycle(self) -> list[TaskTable]:
"""One intake pass: scan, dedup, extract, draft for board review.
Empty list unless both the vault AND intake flags are on, the intake
dir exists, the open-draft cap isn't already reached, and the RoboCo
project resolves."""
if not (settings.obsidian_vault_enabled and settings.vault_intake_enabled):
return []
intake_dir = Path(settings.vault_path) / settings.vault_intake_dir
if not intake_dir.is_dir():
return []
task_svc = get_task_service(self.session)
open_count = len(await task_svc.list_open_vault_note_drafts())
if open_count >= settings.vault_intake_max_open_drafts:
self.log.info("vault-intake: open-draft cap reached; skipping cycle")
return []
project = await self._roboco_project()
if project is None or project.id is None:
self.log.warning("vault-intake: RoboCo project not resolvable; skipping")
return []
return await self._process_notes(
sorted(intake_dir.glob("*.md")), cast("UUID", project.id), open_count
)
async def _roboco_project(self) -> ProjectTable | None:
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _process_notes(
self, note_paths: list[Path], project_id: UUID, open_count: int
) -> list[TaskTable]:
"""Per-note isolation: one bad note (unreadable, malformed) is
skipped and logged rather than aborting the rest of the cycle."""
originated: list[TaskTable] = []
for note_path in note_paths:
if len(originated) >= settings.vault_intake_max_per_cycle:
break
if open_count + len(originated) >= settings.vault_intake_max_open_drafts:
break
try:
task = await self._process_note(note_path, project_id)
except OSError as exc:
self.log.warning(
"vault-intake: note read failed (skipped)",
path=str(note_path),
error=str(exc),
)
continue
if task is not None:
originated.append(task)
return originated
async def _process_note(
self, note_path: Path, project_id: UUID
) -> TaskTable | None:
raw = note_path.read_text(encoding="utf-8")
frontmatter, body = _split_frontmatter(raw)
if not _has_roboco_tag(frontmatter, body):
return None
rel_path = str(note_path.relative_to(Path(settings.vault_path)))
content_hash = _content_hash(raw)
if await self._already_seen(rel_path, content_hash):
return None
screened = screen_external_text(body, source=f"vault_note:{rel_path}")
if screened.flagged:
self.log.warning(
"vault-intake: injection pattern detected in note body",
path=rel_path,
hits=screened.hits,
)
extraction = await self._extract(screened.rendered, note_path.stem)
extraction = _NoteExtraction(
extraction.title,
extraction.description,
_clamp_action_items(extraction.action_items),
)
task = await self._originate(
project_id=project_id,
rel_path=rel_path,
content_hash=content_hash,
extraction=extraction,
)
self.session.add(
VaultSeenNoteTable(note_path=rel_path, content_hash=content_hash)
)
await self.session.flush()
self._append_feedback_callout(note_path, task)
return task
async def _extract(
self, screened_body: str, fallback_title: str
) -> _NoteExtraction:
try:
raw = await _chat(_extraction_prompt(screened_body))
except Exception as exc:
self.log.warning(
"vault-intake: local-model extraction failed (fallback)",
error=str(exc),
)
raw = None
parsed = _parse_extraction(raw) if raw else None
return parsed or _deterministic_extract(screened_body, fallback_title)
async def _already_seen(self, rel_path: str, content_hash: str) -> bool:
result = await self.session.execute(
select(VaultSeenNoteTable.id)
.where(
VaultSeenNoteTable.note_path == rel_path,
VaultSeenNoteTable.content_hash == content_hash,
)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def _originate(
self,
*,
project_id: UUID,
rel_path: str,
content_hash: str,
extraction: _NoteExtraction,
) -> TaskTable:
"""Open ONE PENDING board-review draft (the intake "Board review &
Start" shape: Product-Owner-assigned, ``team=board``). The board
reviews it; delivery starts only at the CEO's ``approve_and_start``,
which flips team to MAIN_PM and hands it to the Main PM.
``confirmed_by_human=True`` matches the intake path — the board
routing is the start gate, not the confirm flag. PLANNING-typed: the
post-approve owner is the Main PM, which never owns code (the type
``approve_and_start`` would coerce to anyway)."""
task_svc = get_task_service(self.session)
task = await task_svc.create(
TaskCreateRequest(
title=f"Vault note: {extraction.title}"[:_TITLE_MAX_CHARS],
description=extraction.description,
acceptance_criteria=extraction.action_items or [_DEFAULT_AC],
team=Team.BOARD,
assigned_to=_foundation.AGENTS["product-owner"].uuid,
created_by=_foundation.AGENTS["system"].uuid,
task_type=TaskType.PLANNING,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=project_id,
status=TaskStatus.PENDING,
source=VAULT_NOTE_SOURCE,
confirmed_by_human=True, # board-routed; approve_and_start is the gate
)
)
markers.set_vault_note_ref(
task,
{
"path": rel_path,
"content_hash": content_hash,
"action_items": extraction.action_items,
},
)
await self.session.flush()
self.log.info(
"vault-intake: board-review draft opened",
task_id=str(task.id),
path=rel_path,
)
return task
def _append_feedback_callout(self, note_path: Path, task: TaskTable) -> None:
"""Best-effort: a filesystem error here never fails the cycle."""
try:
id8 = str(task.id)[:8]
date_str = datetime.now(UTC).strftime("%Y-%m-%d")
line = f"\n> [!info] RoboCo: drafted {task.title} ({id8}) on {date_str}\n"
with note_path.open("a", encoding="utf-8") as fh:
fh.write(line)
except OSError as exc:
self.log.warning(
"vault-intake: feedback callout append failed",
path=str(note_path),
error=str(exc),
)
def get_vault_intake_engine(session: AsyncSession) -> VaultIntakeEngine:
"""Build a VaultIntakeEngine for ``session``."""
return VaultIntakeEngine(session)