Skip to content

memory.dashboard.backend.routes.entries

memory.dashboard.backend.routes.entries

Entry, drift, and plan-report routes.

Split out of the former flat routes.py; behaviour is unchanged.

add_parent

add_parent(entry_id: str, req: AddParentRequest)

Add a secondary parent to an entry via a contains edge.

Source code in memory/dashboard/backend/routes/entries.py
@router.post("/entries/{entry_id}/parents")
def add_parent(entry_id: str, req: AddParentRequest):
    """Add a secondary parent to an entry via a `contains` edge."""
    if federation.split_namespace(entry_id) is not None or federation.split_namespace(req.parent_id) is not None:
        raise HTTPException(status_code=403, detail="Federated entries are read-only")

    try:
        graph = loader.graph
    except FileNotFoundError:
        raise HTTPException(status_code=503, detail="Memory file not found")

    # Validate both entries exist
    entry_rows = list(graph.cypher(
        "MATCH (e:entry) WHERE e.id = $eid RETURN e.id AS id",
        params={"eid": entry_id},
    ))
    if not entry_rows:
        raise HTTPException(status_code=404, detail=f"Entry not found: {entry_id}")

    parent_rows = list(graph.cypher(
        "MATCH (e:entry) WHERE e.id = $pid RETURN e.id AS id",
        params={"pid": req.parent_id},
    ))
    if not parent_rows:
        raise HTTPException(status_code=404, detail=f"Parent entry not found: {req.parent_id}")

    # Duplicate-edge guard: check if this `contains` edge already exists
    proj = _get_project()
    project_id = proj["id"]
    existing = list(graph.cypher(
        "MATCH (src:entry)-[:contains]->(tgt:entry) "
        "WHERE src.id = $pid AND tgt.id = $eid "
        "RETURN src.id AS source",
        params={"pid": req.parent_id, "eid": entry_id},
    ))
    if existing:
        raise HTTPException(status_code=409, detail="This parent relationship already exists")

    # Cycle detection: walk ancestors of the proposed parent to ensure
    # the target entry isn't already an ancestor (which would create a cycle)
    all_entries = list(graph.cypher(
        "MATCH (e:entry) WHERE e.project = $proj RETURN e.id AS id, e.parent_id AS parent_id",
        params={"proj": project_id},
    ))
    parent_map: dict[str, str | None] = {e["id"]: e.get("parent_id") for e in all_entries}

    # Also gather existing contains edges for full ancestry traversal
    contains = list(graph.cypher(
        "MATCH (src:entry)-[:contains]->(tgt:entry) "
        "WHERE src.project = $proj "
        "RETURN src.id AS source, tgt.id AS target",
        params={"proj": project_id},
    ))
    extra_parents: dict[str, set[str]] = {}
    for c in contains:
        extra_parents.setdefault(c["target"], set()).add(c["source"])

    def get_all_ancestors(node_id: str) -> set[str]:
        ancestors: set[str] = set()
        stack = [node_id]
        while stack:
            current = stack.pop()
            primary = parent_map.get(current)
            if primary and primary not in ancestors:
                ancestors.add(primary)
                stack.append(primary)
            for ep in extra_parents.get(current, set()):
                if ep not in ancestors:
                    ancestors.add(ep)
                    stack.append(ep)
        return ancestors

    ancestors_of_parent = get_all_ancestors(req.parent_id)
    if entry_id in ancestors_of_parent or entry_id == req.parent_id:
        raise HTTPException(
            status_code=400,
            detail="Adding this parent would create a cycle",
        )

    # Create the `contains` edge
    df = pd.DataFrame({"source": [req.parent_id], "target": [entry_id]})
    graph.add_connections(df, "contains", "entry", "source", "entry", "target")
    atomic_graph_save(graph, MEMORY_FILE)

    return {"status": "ok", "parent_id": req.parent_id, "entry_id": entry_id}

get_plan_report

get_plan_report(entry_id: str)

Auto-generate a structured report from a plan node's subtree.

Source code in memory/dashboard/backend/routes/entries.py
@router.get("/plan-report/{entry_id}")
def get_plan_report(entry_id: str):
    """Auto-generate a structured report from a plan node's subtree."""
    fed = federation.find_tree_for_id(entry_id)
    if fed is not None:
        # Federated (read-only peer) plan: build the plan and its subtree from
        # the peer's namespaced projection rather than the local graph, mirroring
        # how the tree/entry routes surface federated nodes.
        tree, _local_id = fed
        all_entries = federation.project_entries(tree)
        plan = next((e for e in all_entries if e["id"] == entry_id), None)
        if plan is None:
            raise HTTPException(status_code=404, detail=f"Entry not found: {entry_id}")
        contains_edges: list[dict] = []
    else:
        try:
            graph = loader.graph
        except FileNotFoundError:
            raise HTTPException(status_code=503, detail="Memory file not found")

        rows = list(graph.cypher(
            "MATCH (e:entry) WHERE e.id = $eid "
            "RETURN e.id AS id, e.type AS type, e.title AS title, e.body AS body, "
            "e.status AS status, e.success AS success, e.created_at AS created_at",
            params={"eid": entry_id},
        ))
        if not rows:
            raise HTTPException(status_code=404, detail=f"Entry not found: {entry_id}")
        plan = rows[0]

        project_id = _get_project()["id"]

        all_entries = list(graph.cypher(
            "MATCH (e:entry) WHERE e.project = $proj AND e.status = $st "
            "RETURN e.id AS id, e.type AS type, e.title AS title, e.body AS body, "
            "e.parent_id AS parent_id, e.success AS success, e.tags AS tags, "
            "e.created_at AS created_at, e.node_label AS node_label",
            params={"proj": project_id, "st": "active"},
        ))

        # Also include children connected via secondary `contains` edges
        contains_edges = list(graph.cypher(
            "MATCH (src:entry)-[:contains]->(tgt:entry) "
            "WHERE src.project = $proj AND tgt.status = $st "
            "RETURN src.id AS source, tgt.id AS target",
            params={"proj": project_id, "st": "active"},
        ))

    children_of: dict[str, list] = {}
    entry_map = {e["id"]: e for e in all_entries}
    for e in all_entries:
        children_of.setdefault(e["parent_id"], []).append(e)
    for ce in contains_edges:
        if ce["target"] in entry_map:
            children_of.setdefault(ce["source"], []).append(entry_map[ce["target"]])

    subtree: list[dict] = []
    visited: set[str] = set()
    stack = list(children_of.get(entry_id, []))
    while stack:
        e = stack.pop()
        if e["id"] in visited:
            continue
        visited.add(e["id"])
        subtree.append(e)
        stack.extend(children_of.get(e["id"], []))

    decisions = [e for e in subtree if e["type"] == "decision"]
    experiments = [e for e in subtree if e["type"] == "experiment"]
    checkpoints = [e for e in subtree if e["type"] == "checkpoint"]
    todos = [e for e in subtree if e["type"] == "todo"]
    notes = [e for e in subtree if e["type"] in ("note", "annotation")]
    sub_plans = [e for e in subtree if e["type"] == "plan"]

    exp_pass = [e for e in experiments if e.get("success") == "true"]
    exp_fail = [e for e in experiments if e.get("success") == "false"]
    exp_pending = [e for e in experiments if e.get("success") not in ("true", "false")]

    lines = []
    lines.append(f"# {plan['title']}")
    lines.append("")

    if subtree:
        sorted_entries = sorted(subtree, key=lambda e: e.get("created_at", ""))
        first = sorted_entries[0].get("created_at", "")[:10] if sorted_entries else "?"
        last = sorted_entries[-1].get("created_at", "")[:10] if sorted_entries else "?"
        lines.append(f"**Period:** {first}{last}  ")
    lines.append(f"**Status:** {plan.get('status', 'active')}  ")
    lines.append(f"**Total entries:** {len(subtree)}")
    lines.append("")

    lines.append("---")
    lines.append("")
    lines.append("## Summary")
    lines.append("")
    summary_parts = []
    if decisions:
        summary_parts.append(f"{len(decisions)} decision{'s' if len(decisions) != 1 else ''}")
    if experiments:
        exp_summary = f"{len(experiments)} experiment{'s' if len(experiments) != 1 else ''}"
        details = []
        if exp_pass:
            details.append(f"{len(exp_pass)} passed")
        if exp_fail:
            details.append(f"{len(exp_fail)} failed")
        if exp_pending:
            details.append(f"{len(exp_pending)} pending")
        if details:
            exp_summary += f" ({', '.join(details)})"
        summary_parts.append(exp_summary)
    if checkpoints:
        summary_parts.append(f"{len(checkpoints)} checkpoint{'s' if len(checkpoints) != 1 else ''}")
    if todos:
        summary_parts.append(f"{len(todos)} open todo{'s' if len(todos) != 1 else ''}")
    if notes:
        summary_parts.append(f"{len(notes)} note{'s' if len(notes) != 1 else ''}")
    if sub_plans:
        summary_parts.append(f"{len(sub_plans)} sub-plan{'s' if len(sub_plans) != 1 else ''}")
    lines.append("This plan contains " + ", ".join(summary_parts) + ".")
    lines.append("")

    if decisions:
        lines.append("---")
        lines.append("")
        lines.append("## Decisions")
        lines.append("")
        for d in sorted(decisions, key=lambda e: e.get("created_at", "")):
            lines.append(f"### {d['title']}")
            if d.get("body"):
                lines.append("")
                lines.append(d["body"])
            lines.append("")

    if experiments:
        lines.append("---")
        lines.append("")
        lines.append("## Experiments")
        lines.append("")
        for e in sorted(experiments, key=lambda e: e.get("created_at", "")):
            status_icon = "✓" if e.get("success") == "true" else "✗" if e.get("success") == "false" else "?"
            lines.append(f"- **{status_icon} {e['title']}**")
            if e.get("body"):
                body_preview = e["body"][:200]
                if len(e["body"]) > 200:
                    body_preview += "..."
                lines.append(f"  {body_preview}")
        lines.append("")

    if checkpoints:
        lines.append("---")
        lines.append("")
        lines.append("## Checkpoints")
        lines.append("")
        for c in sorted(checkpoints, key=lambda e: e.get("created_at", "")):
            date = c.get("created_at", "")[:10]
            lines.append(f"- **{date}** — {c['title']}")
            if c.get("body"):
                body_preview = c["body"][:300]
                if len(c["body"]) > 300:
                    body_preview += "..."
                lines.append(f"  {body_preview}")
        lines.append("")

    if todos:
        lines.append("---")
        lines.append("")
        lines.append("## Open Items")
        lines.append("")
        for t in todos:
            lines.append(f"- [ ] {t['title']}")
        lines.append("")

    if notes:
        lines.append("---")
        lines.append("")
        lines.append("## Notes & Annotations")
        lines.append("")
        for n in sorted(notes, key=lambda e: e.get("created_at", "")):
            label = "Note" if n["type"] == "note" else "Annotation"
            lines.append(f"- **[{label}]** {n['title']}")
            if n.get("body"):
                body_preview = n["body"][:200]
                if len(n["body"]) > 200:
                    body_preview += "..."
                lines.append(f"  {body_preview}")
        lines.append("")

    return {"entry_id": entry_id, "title": plan["title"], "report": "\n".join(lines)}