From 032d38efc3d2ad3506d143fbd5c2de54a5e45525 Mon Sep 17 00:00:00 2001 From: Redacted User Date: Fri, 10 Apr 2026 08:11:44 +0200 Subject: [PATCH] Separate continuity lifecycle states --- ..._0049_continuity_object_lifecycle_flags.py | 54 ++++++ apps/api/src/alicebot_api/cli.py | 114 ++++++++++++ apps/api/src/alicebot_api/cli_formatting.py | 97 ++++++++++ .../src/alicebot_api/continuity_lifecycle.py | 121 +++++++++++++ .../src/alicebot_api/continuity_objects.py | 54 ++++++ .../src/alicebot_api/continuity_open_loops.py | 8 + .../api/src/alicebot_api/continuity_recall.py | 7 + .../src/alicebot_api/continuity_resumption.py | 16 ++ .../api/src/alicebot_api/continuity_review.py | 14 ++ apps/api/src/alicebot_api/contracts.py | 67 +++++++ apps/api/src/alicebot_api/main.py | 63 +++++++ apps/api/src/alicebot_api/mcp_tools.py | 19 ++ apps/api/src/alicebot_api/store.py | 67 +++++++ tests/integration/test_cli_integration.py | 73 ++++++++ .../integration/test_continuity_recall_api.py | 166 ++++++++++++++++++ .../test_continuity_resumption_api.py | 110 ++++++++++++ ..._0049_continuity_object_lifecycle_flags.py | 32 ++++ tests/unit/test_chief_of_staff.py | 24 +++ tests/unit/test_cli.py | 19 ++ tests/unit/test_continuity_capture.py | 6 + tests/unit/test_continuity_objects.py | 14 ++ tests/unit/test_continuity_open_loops.py | 12 ++ tests/unit/test_continuity_recall.py | 22 +++ tests/unit/test_continuity_resumption.py | 57 ++++++ tests/unit/test_continuity_review.py | 16 ++ tests/unit/test_main.py | 2 + 26 files changed, 1254 insertions(+) create mode 100644 apps/api/alembic/versions/20260410_0049_continuity_object_lifecycle_flags.py create mode 100644 apps/api/src/alicebot_api/continuity_lifecycle.py create mode 100644 tests/unit/test_20260410_0049_continuity_object_lifecycle_flags.py diff --git a/apps/api/alembic/versions/20260410_0049_continuity_object_lifecycle_flags.py b/apps/api/alembic/versions/20260410_0049_continuity_object_lifecycle_flags.py new file mode 100644 index 0000000..a68f925 --- /dev/null +++ b/apps/api/alembic/versions/20260410_0049_continuity_object_lifecycle_flags.py @@ -0,0 +1,54 @@ +"""Separate preservation, searchability, and promotability lifecycle flags.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260410_0049" +down_revision = "20260410_0048" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + "ALTER TABLE continuity_objects ADD COLUMN is_preserved boolean NOT NULL DEFAULT TRUE", + "ALTER TABLE continuity_objects ADD COLUMN is_searchable boolean NOT NULL DEFAULT TRUE", + "ALTER TABLE continuity_objects ADD COLUMN is_promotable boolean NOT NULL DEFAULT TRUE", + ( + "UPDATE continuity_objects " + "SET is_searchable = CASE WHEN object_type = 'Note' THEN FALSE ELSE TRUE END, " + " is_promotable = CASE " + " WHEN object_type IN ('Decision', 'Commitment', 'WaitingFor', 'Blocker', 'NextAction') THEN TRUE " + " ELSE FALSE " + " END" + ), + ( + "CREATE INDEX continuity_objects_user_searchable_updated_idx " + "ON continuity_objects (user_id, is_searchable, updated_at DESC, id DESC)" + ), + ( + "CREATE INDEX continuity_objects_user_promotable_updated_idx " + "ON continuity_objects (user_id, is_promotable, updated_at DESC, id DESC)" + ), +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS continuity_objects_user_promotable_updated_idx", + "DROP INDEX IF EXISTS continuity_objects_user_searchable_updated_idx", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS is_promotable", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS is_searchable", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS is_preserved", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/src/alicebot_api/cli.py b/apps/api/src/alicebot_api/cli.py index eec79f9..213e58e 100644 --- a/apps/api/src/alicebot_api/cli.py +++ b/apps/api/src/alicebot_api/cli.py @@ -14,6 +14,8 @@ from alicebot_api.cli_formatting import ( format_capture_output, + format_lifecycle_detail_output, + format_lifecycle_list_output, format_open_loops_output, format_recall_output, format_resume_output, @@ -27,6 +29,16 @@ ContinuityCaptureValidationError, capture_continuity_input, ) +from alicebot_api.continuity_objects import ( + default_continuity_promotable, + default_continuity_searchable, +) +from alicebot_api.continuity_lifecycle import ( + ContinuityLifecycleNotFoundError, + ContinuityLifecycleValidationError, + get_continuity_lifecycle_state, + list_continuity_lifecycle_state, +) from alicebot_api.continuity_open_loops import ( ContinuityOpenLoopValidationError, compile_continuity_open_loop_dashboard, @@ -49,6 +61,7 @@ from alicebot_api.contracts import ( CONTINUITY_CAPTURE_EXPLICIT_SIGNALS, CONTINUITY_CORRECTION_ACTIONS, + DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, DEFAULT_CONTINUITY_RECALL_LIMIT, DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, @@ -56,11 +69,13 @@ DEFAULT_CONTINUITY_REVIEW_LIMIT, MAX_CONTINUITY_OPEN_LOOP_LIMIT, MAX_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_LIFECYCLE_LIMIT, MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, MAX_CONTINUITY_REVIEW_LIMIT, ContinuityCaptureCreateInput, ContinuityCorrectionInput, + ContinuityLifecycleQueryInput, ContinuityOpenLoopDashboardQueryInput, ContinuityRecallQueryInput, ContinuityResumptionBriefRequestInput, @@ -176,6 +191,26 @@ def _run_recall(ctx: CLIContext, args: argparse.Namespace) -> str: return format_recall_output(payload) +def _run_lifecycle_list(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = list_continuity_lifecycle_state( + store, + user_id=ctx.user_id, + request=ContinuityLifecycleQueryInput(limit=args.limit), + ) + return format_lifecycle_list_output(payload) + + +def _run_lifecycle_show(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = get_continuity_lifecycle_state( + store, + user_id=ctx.user_id, + continuity_object_id=args.continuity_object_id, + ) + return format_lifecycle_detail_output(payload) + + def _run_resume(ctx: CLIContext, args: argparse.Namespace) -> str: with _store_context(ctx) as store: payload = compile_continuity_resumption_brief( @@ -191,6 +226,7 @@ def _run_resume(ctx: CLIContext, args: argparse.Namespace) -> str: until=args.until, max_recent_changes=args.max_recent_changes, max_open_loops=args.max_open_loops, + include_non_promotable_facts=args.include_non_promotable_facts, ), ) return format_resume_output(payload) @@ -286,6 +322,10 @@ def _run_status(ctx: CLIContext, _args: argparse.Namespace) -> str: "continuity_objects_stale": 0, "continuity_objects_superseded": 0, "continuity_objects_deleted": 0, + "continuity_objects_searchable": 0, + "continuity_objects_non_searchable": 0, + "continuity_objects_promotable": 0, + "continuity_objects_non_promotable": 0, "review_correction_ready": 0, "review_active": 0, "review_stale": 0, @@ -343,6 +383,46 @@ def _run_status(ctx: CLIContext, _args: argparse.Namespace) -> str: "continuity_objects_stale": object_status_counts["stale"], "continuity_objects_superseded": object_status_counts["superseded"], "continuity_objects_deleted": object_status_counts["deleted"], + "continuity_objects_searchable": sum( + 1 + for candidate in recall_candidates + if bool( + candidate.get( + "is_searchable", + default_continuity_searchable(str(candidate["object_type"])), + ) + ) + ), + "continuity_objects_non_searchable": sum( + 1 + for candidate in recall_candidates + if not bool( + candidate.get( + "is_searchable", + default_continuity_searchable(str(candidate["object_type"])), + ) + ) + ), + "continuity_objects_promotable": sum( + 1 + for candidate in recall_candidates + if bool( + candidate.get( + "is_promotable", + default_continuity_promotable(str(candidate["object_type"])), + ) + ) + ), + "continuity_objects_non_promotable": sum( + 1 + for candidate in recall_candidates + if not bool( + candidate.get( + "is_promotable", + default_continuity_promotable(str(candidate["object_type"])), + ) + ) + ), "review_correction_ready": review_counts["active"] + review_counts["stale"], "review_active": review_counts["active"], "review_stale": review_counts["stale"], @@ -403,6 +483,26 @@ def build_parser() -> argparse.ArgumentParser: ) recall_parser.set_defaults(handler=_run_recall) + lifecycle_parser = subparsers.add_parser("lifecycle", help="Inspect continuity lifecycle state.") + lifecycle_subparsers = lifecycle_parser.add_subparsers(dest="lifecycle_command", required=True) + + lifecycle_list_parser = lifecycle_subparsers.add_parser("list", help="List lifecycle states.") + lifecycle_list_parser.add_argument( + "--limit", + type=int, + default=DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, + help=f"Max lifecycle results (1-{MAX_CONTINUITY_LIFECYCLE_LIMIT}).", + ) + lifecycle_list_parser.set_defaults(handler=_run_lifecycle_list) + + lifecycle_show_parser = lifecycle_subparsers.add_parser("show", help="Show one lifecycle state.") + lifecycle_show_parser.add_argument( + "continuity_object_id", + type=_parse_uuid, + help="Continuity object UUID.", + ) + lifecycle_show_parser.set_defaults(handler=_run_lifecycle_show) + resume_parser = subparsers.add_parser("resume", help="Compile continuity resumption brief.") _add_scope_filter_arguments(resume_parser) resume_parser.add_argument( @@ -417,6 +517,11 @@ def build_parser() -> argparse.ArgumentParser: default=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, help=f"Open loop limit (0-{MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT}).", ) + resume_parser.add_argument( + "--include-non-promotable-facts", + action="store_true", + help="Include searchable but non-promotable facts in recent changes.", + ) resume_parser.set_defaults(handler=_run_resume) open_loops_parser = subparsers.add_parser( @@ -522,6 +627,13 @@ def _validate_arguments(args: argparse.Namespace) -> None: minimum=1, maximum=MAX_CONTINUITY_RECALL_LIMIT, ) + elif args.command == "lifecycle" and args.lifecycle_command == "list": + _validate_limit( + args.limit, + option_name="--limit", + minimum=1, + maximum=MAX_CONTINUITY_LIFECYCLE_LIMIT, + ) elif args.command == "resume": _validate_limit( args.max_recent_changes, @@ -564,6 +676,8 @@ def main(argv: list[str] | None = None) -> int: ValueError, psycopg.Error, ContinuityCaptureValidationError, + ContinuityLifecycleValidationError, + ContinuityLifecycleNotFoundError, ContinuityRecallValidationError, ContinuityResumptionValidationError, ContinuityOpenLoopValidationError, diff --git a/apps/api/src/alicebot_api/cli_formatting.py b/apps/api/src/alicebot_api/cli_formatting.py index f31f38e..8c5e8ef 100644 --- a/apps/api/src/alicebot_api/cli_formatting.py +++ b/apps/api/src/alicebot_api/cli_formatting.py @@ -6,6 +6,8 @@ from alicebot_api.contracts import ( ContinuityCaptureCreateResponse, ContinuityCorrectionApplyResponse, + ContinuityLifecycleDetailResponse, + ContinuityLifecycleListResponse, ContinuityOpenLoopDashboardResponse, ContinuityRecallResultRecord, ContinuityRecallResponse, @@ -78,6 +80,11 @@ def _render_recall_item( lines = [ f"{prefix}{marker} [{item['object_type']}|{item['status']}] {item['title']}", f"{prefix} id={item['id']} capture_event_id={item['capture_event_id']}", + ( + f"{prefix} lifecycle=preserved:{item['lifecycle']['is_preserved']} " + f"searchable:{item['lifecycle']['is_searchable']} " + f"promotable:{item['lifecycle']['is_promotable']}" + ), ( f"{prefix} confidence={_format_float(item['confidence'])} " f"relevance={_format_float(item['relevance'])} " @@ -137,6 +144,12 @@ def format_capture_output(payload: ContinuityCaptureCreateResponse) -> str: f"derived_object_id: {derived['id']}", f"derived_object_type: {derived['object_type']}", f"derived_object_status: {derived['status']}", + ( + "derived_lifecycle: " + f"preserved={derived['lifecycle']['is_preserved']} " + f"searchable={derived['lifecycle']['is_searchable']} " + f"promotable={derived['lifecycle']['is_promotable']}" + ), f"derived_confidence: {_format_float(derived['confidence'])}", f"derived_title: {derived['title']}", ] @@ -268,6 +281,12 @@ def format_review_queue_output(payload: ContinuityReviewQueueResponse) -> str: [ f"{index}. [{item['object_type']}|{item['status']}] {item['title']}", f" id={item['id']} capture_event_id={item['capture_event_id']}", + ( + " lifecycle=" + f"preserved:{item['lifecycle']['is_preserved']} " + f"searchable:{item['lifecycle']['is_searchable']} " + f"promotable:{item['lifecycle']['is_promotable']}" + ), f" confidence={_format_float(item['confidence'])} last_confirmed_at={item['last_confirmed_at']}", f" provenance={_format_json(item['provenance'])}", ] @@ -286,6 +305,12 @@ def format_review_detail_output(payload: ContinuityReviewDetailResponse) -> str: f"continuity_object_id: {continuity_object['id']}", f"type: {continuity_object['object_type']}", f"status: {continuity_object['status']}", + ( + "lifecycle: " + f"preserved={continuity_object['lifecycle']['is_preserved']} " + f"searchable={continuity_object['lifecycle']['is_searchable']} " + f"promotable={continuity_object['lifecycle']['is_promotable']}" + ), f"title: {continuity_object['title']}", f"confidence: {_format_float(continuity_object['confidence'])}", f"last_confirmed_at: {continuity_object['last_confirmed_at']}", @@ -317,6 +342,12 @@ def format_review_apply_output(payload: ContinuityCorrectionApplyResponse) -> st "review apply result", f"continuity_object_id: {continuity_object['id']}", f"continuity_object_status: {continuity_object['status']}", + ( + "continuity_object_lifecycle: " + f"preserved={continuity_object['lifecycle']['is_preserved']} " + f"searchable={continuity_object['lifecycle']['is_searchable']} " + f"promotable={continuity_object['lifecycle']['is_promotable']}" + ), f"continuity_object_title: {continuity_object['title']}", f"correction_event_id: {correction_event['id']}", f"correction_action: {correction_event['action']}", @@ -352,6 +383,13 @@ def format_status_output(status: Mapping[str, object]) -> str: f"superseded={status['continuity_objects_superseded']} " f"deleted={status['continuity_objects_deleted']}" ), + ( + "continuity_object_lifecycle: " + f"searchable={status['continuity_objects_searchable']} " + f"non_searchable={status['continuity_objects_non_searchable']} " + f"promotable={status['continuity_objects_promotable']} " + f"non_promotable={status['continuity_objects_non_promotable']}" + ), ( "review_queue: " f"correction_ready={status['review_correction_ready']} " @@ -376,3 +414,62 @@ def format_status_output(status: Mapping[str, object]) -> str: ), ] return "\n".join(lines) + + +def format_lifecycle_list_output(payload: ContinuityLifecycleListResponse) -> str: + summary = payload["summary"] + lines = [ + "continuity lifecycle", + ( + f"returned: {summary['returned_count']}/{summary['total_count']} " + f"(limit={summary['limit']})" + ), + ( + "counts: " + f"preserved={summary['counts']['preserved_count']} " + f"searchable={summary['counts']['searchable_count']} " + f"promotable={summary['counts']['promotable_count']} " + f"non_searchable={summary['counts']['not_searchable_count']} " + f"non_promotable={summary['counts']['not_promotable_count']}" + ), + f"order: {', '.join(summary['order'])}", + ] + if len(payload["items"]) == 0: + lines.append("empty: no continuity lifecycle records.") + return "\n".join(lines) + + for index, item in enumerate(payload["items"], start=1): + lines.extend( + [ + f"{index}. [{item['object_type']}|{item['status']}] {item['title']}", + f" id={item['id']} capture_event_id={item['capture_event_id']}", + ( + " lifecycle=" + f"preserved:{item['lifecycle']['is_preserved']} " + f"searchable:{item['lifecycle']['is_searchable']} " + f"promotable:{item['lifecycle']['is_promotable']}" + ), + ] + ) + return "\n".join(lines) + + +def format_lifecycle_detail_output(payload: ContinuityLifecycleDetailResponse) -> str: + item = payload["continuity_object"] + return "\n".join( + [ + "continuity lifecycle detail", + f"continuity_object_id: {item['id']}", + f"type: {item['object_type']}", + f"status: {item['status']}", + ( + "lifecycle: " + f"preserved={item['lifecycle']['is_preserved']} " + f"searchable={item['lifecycle']['is_searchable']} " + f"promotable={item['lifecycle']['is_promotable']}" + ), + f"title: {item['title']}", + f"body: {_format_json(item['body'])}", + f"provenance: {_format_json(item['provenance'])}", + ] + ) diff --git a/apps/api/src/alicebot_api/continuity_lifecycle.py b/apps/api/src/alicebot_api/continuity_lifecycle.py new file mode 100644 index 0000000..5c81e9f --- /dev/null +++ b/apps/api/src/alicebot_api/continuity_lifecycle.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from uuid import UUID + +from alicebot_api.continuity_objects import serialize_continuity_lifecycle_state_from_record +from alicebot_api.contracts import ( + CONTINUITY_LIFECYCLE_LIST_ORDER, + DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, + MAX_CONTINUITY_LIFECYCLE_LIMIT, + ContinuityLifecycleDetailResponse, + ContinuityLifecycleListResponse, + ContinuityLifecycleQueryInput, + ContinuityReviewObjectRecord, + isoformat_or_none, +) +from alicebot_api.store import ContinuityObjectRow, ContinuityRecallCandidateRow, ContinuityStore + + +class ContinuityLifecycleValidationError(ValueError): + """Raised when a continuity lifecycle inspection request is invalid.""" + + +class ContinuityLifecycleNotFoundError(LookupError): + """Raised when a continuity object is not visible in scope.""" + + +def _serialize_object(record: ContinuityObjectRow | ContinuityRecallCandidateRow) -> ContinuityReviewObjectRecord: + return { + "id": str(record["id"]), + "capture_event_id": str(record["capture_event_id"]), + "object_type": record["object_type"], # type: ignore[typeddict-item] + "status": record["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(record), + "title": record["title"], + "body": record["body"], + "provenance": record["provenance"], + "confidence": float(record["confidence"]), + "last_confirmed_at": isoformat_or_none(record["last_confirmed_at"]), + "supersedes_object_id": ( + None if record["supersedes_object_id"] is None else str(record["supersedes_object_id"]) + ), + "superseded_by_object_id": ( + None if record["superseded_by_object_id"] is None else str(record["superseded_by_object_id"]) + ), + "created_at": ( + record["object_created_at"].isoformat() + if "object_created_at" in record + else record["created_at"].isoformat() + ), + "updated_at": ( + record["object_updated_at"].isoformat() + if "object_updated_at" in record + else record["updated_at"].isoformat() + ), + } + + +def _validate_limit(limit: int) -> None: + if limit < 1 or limit > MAX_CONTINUITY_LIFECYCLE_LIMIT: + raise ContinuityLifecycleValidationError( + f"limit must be between 1 and {MAX_CONTINUITY_LIFECYCLE_LIMIT}" + ) + + +def list_continuity_lifecycle_state( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityLifecycleQueryInput, +) -> ContinuityLifecycleListResponse: + del user_id + + _validate_limit(request.limit) + rows = sorted( + store.list_continuity_recall_candidates(), + key=lambda row: (row["object_updated_at"], str(row["id"])), + reverse=True, + ) + items = [_serialize_object(row) for row in rows[: request.limit]] + total_count = len(rows) + searchable_count = sum(1 for row in rows if row["is_searchable"]) + promotable_count = sum(1 for row in rows if row["is_promotable"]) + + return { + "items": items, + "summary": { + "limit": request.limit, + "returned_count": len(items), + "total_count": total_count, + "counts": { + "preserved_count": sum(1 for row in rows if row["is_preserved"]), + "searchable_count": searchable_count, + "promotable_count": promotable_count, + "not_searchable_count": total_count - searchable_count, + "not_promotable_count": total_count - promotable_count, + }, + "order": list(CONTINUITY_LIFECYCLE_LIST_ORDER), + }, + } + + +def get_continuity_lifecycle_state( + store: ContinuityStore, + *, + user_id: UUID, + continuity_object_id: UUID, +) -> ContinuityLifecycleDetailResponse: + del user_id + + record = store.get_continuity_object_optional(continuity_object_id) + if record is None: + raise ContinuityLifecycleNotFoundError( + f"continuity object {continuity_object_id} was not found" + ) + return { + "continuity_object": _serialize_object(record), + } + + +def build_default_continuity_lifecycle_query() -> ContinuityLifecycleQueryInput: + return ContinuityLifecycleQueryInput(limit=DEFAULT_CONTINUITY_LIFECYCLE_LIMIT) diff --git a/apps/api/src/alicebot_api/continuity_objects.py b/apps/api/src/alicebot_api/continuity_objects.py index 762532f..fac5c1a 100644 --- a/apps/api/src/alicebot_api/continuity_objects.py +++ b/apps/api/src/alicebot_api/continuity_objects.py @@ -1,9 +1,11 @@ from __future__ import annotations +from collections.abc import Mapping from uuid import UUID from alicebot_api.contracts import ( CONTINUITY_OBJECT_TYPES, + ContinuityLifecycleStateRecord, ContinuityObjectRecord, ) from alicebot_api.store import ContinuityObjectRow, ContinuityStore, JsonObject @@ -13,12 +15,48 @@ class ContinuityObjectValidationError(ValueError): """Raised when a continuity object request is invalid.""" +def default_continuity_searchable(object_type: str) -> bool: + return object_type != "Note" + + +def default_continuity_promotable(object_type: str) -> bool: + return object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"} + + +def serialize_continuity_lifecycle_state( + *, + is_preserved: bool, + is_searchable: bool, + is_promotable: bool, +) -> ContinuityLifecycleStateRecord: + return { + "is_preserved": is_preserved, + "preservation_status": "preserved" if is_preserved else "not_preserved", + "is_searchable": is_searchable, + "searchability_status": "searchable" if is_searchable else "not_searchable", + "is_promotable": is_promotable, + "promotion_status": "promotable" if is_promotable else "not_promotable", + } + + +def serialize_continuity_lifecycle_state_from_record( + record: Mapping[str, object], +) -> ContinuityLifecycleStateRecord: + object_type = str(record["object_type"]) + return serialize_continuity_lifecycle_state( + is_preserved=bool(record.get("is_preserved", True)), + is_searchable=bool(record.get("is_searchable", default_continuity_searchable(object_type))), + is_promotable=bool(record.get("is_promotable", default_continuity_promotable(object_type))), + ) + + def _serialize_continuity_object(record: ContinuityObjectRow) -> ContinuityObjectRecord: return { "id": str(record["id"]), "capture_event_id": str(record["capture_event_id"]), "object_type": record["object_type"], "status": record["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(record), "title": record["title"], "body": record["body"], "provenance": record["provenance"], @@ -60,17 +98,33 @@ def create_continuity_object_record( provenance: JsonObject, confidence: float, status: str = "active", + is_preserved: bool = True, + is_searchable: bool | None = None, + is_promotable: bool | None = None, ) -> ContinuityObjectRecord: del user_id _validate_object_type(object_type) _validate_title(title) _validate_confidence(confidence) + resolved_is_searchable = ( + default_continuity_searchable(object_type) + if is_searchable is None + else is_searchable + ) + resolved_is_promotable = ( + default_continuity_promotable(object_type) + if is_promotable is None + else is_promotable + ) row = store.create_continuity_object( capture_event_id=capture_event_id, object_type=object_type, status=status, + is_preserved=is_preserved, + is_searchable=resolved_is_searchable, + is_promotable=resolved_is_promotable, title=title.strip(), body=body, provenance=provenance, diff --git a/apps/api/src/alicebot_api/continuity_open_loops.py b/apps/api/src/alicebot_api/continuity_open_loops.py index 0c1700a..d426653 100644 --- a/apps/api/src/alicebot_api/continuity_open_loops.py +++ b/apps/api/src/alicebot_api/continuity_open_loops.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime from uuid import UUID +from alicebot_api.continuity_objects import serialize_continuity_lifecycle_state_from_record from alicebot_api.continuity_recall import query_continuity_recall from alicebot_api.contracts import ( CONTINUITY_DAILY_BRIEF_ASSEMBLY_VERSION_V0, @@ -96,6 +97,7 @@ def _serialize_review_object(record: ContinuityObjectRow) -> ContinuityReviewObj "capture_event_id": str(record["capture_event_id"]), "object_type": record["object_type"], # type: ignore[typeddict-item] "status": record["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(record), "title": record["title"], "body": record["body"], "provenance": record["provenance"], @@ -131,6 +133,9 @@ def _snapshot(record: ContinuityObjectRow) -> JsonObject: "capture_event_id": str(record["capture_event_id"]), "object_type": record["object_type"], "status": record["status"], + "is_preserved": record["is_preserved"], + "is_searchable": record["is_searchable"], + "is_promotable": record["is_promotable"], "title": record["title"], "body": record["body"], "provenance": record["provenance"], @@ -540,6 +545,9 @@ def apply_continuity_open_loop_review_action( updated = store.update_continuity_object_optional( continuity_object_id=continuity_object_id, status=transition.lifecycle_outcome, + is_preserved=current["is_preserved"], + is_searchable=current["is_searchable"], + is_promotable=current["is_promotable"], title=current["title"], body=current["body"], provenance=current["provenance"], diff --git a/apps/api/src/alicebot_api/continuity_recall.py b/apps/api/src/alicebot_api/continuity_recall.py index 51d832d..aa317e7 100644 --- a/apps/api/src/alicebot_api/continuity_recall.py +++ b/apps/api/src/alicebot_api/continuity_recall.py @@ -6,6 +6,10 @@ from typing import cast from uuid import UUID +from alicebot_api.continuity_objects import ( + default_continuity_searchable, + serialize_continuity_lifecycle_state_from_record, +) from alicebot_api.contracts import ( CONTINUITY_RECALL_LIST_ORDER, DEFAULT_CONTINUITY_RECALL_LIMIT, @@ -473,6 +477,7 @@ def _serialize_recall_result(item: RankedRecallCandidate) -> ContinuityRecallRes "capture_event_id": str(row["capture_event_id"]), "object_type": row["object_type"], # type: ignore[typeddict-item] "status": row["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(row), "title": row["title"], "body": row["body"], "provenance": row["provenance"], @@ -548,6 +553,8 @@ def _ordered_recall_candidates( for row in store.list_continuity_recall_candidates(): if row["status"] == "deleted": continue + if not bool(row.get("is_searchable", default_continuity_searchable(row["object_type"]))): + continue if not _matches_time_window( row, diff --git a/apps/api/src/alicebot_api/continuity_resumption.py b/apps/api/src/alicebot_api/continuity_resumption.py index 782e518..4bc8072 100644 --- a/apps/api/src/alicebot_api/continuity_resumption.py +++ b/apps/api/src/alicebot_api/continuity_resumption.py @@ -37,6 +37,18 @@ def _is_recent_change_candidate(item: ContinuityRecallResultRecord) -> bool: return item["status"] in {"active", "stale", "superseded", "completed", "cancelled"} +def _is_promotable_fact( + item: ContinuityRecallResultRecord, + *, + include_non_promotable_facts: bool, +) -> bool: + if item["object_type"] != "MemoryFact": + return True + if include_non_promotable_facts: + return True + return item["lifecycle"]["is_promotable"] + + def _build_empty_state(*, is_empty: bool, message: str) -> ContinuityResumptionEmptyState: return { "is_empty": is_empty, @@ -165,6 +177,10 @@ def compile_continuity_resumption_brief( item for item in recent_ordered_items if _is_recent_change_candidate(item) + and _is_promotable_fact( + item, + include_non_promotable_facts=request.include_non_promotable_facts, + ) ] recent_change_items = ( recent_change_candidates[: request.max_recent_changes] diff --git a/apps/api/src/alicebot_api/continuity_review.py b/apps/api/src/alicebot_api/continuity_review.py index 5faea96..685b663 100644 --- a/apps/api/src/alicebot_api/continuity_review.py +++ b/apps/api/src/alicebot_api/continuity_review.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime from uuid import UUID +from alicebot_api.continuity_objects import serialize_continuity_lifecycle_state_from_record from alicebot_api.contracts import ( CONTINUITY_CORRECTION_ACTIONS, CONTINUITY_REVIEW_QUEUE_ORDER, @@ -80,6 +81,7 @@ def _serialize_review_object(record: ContinuityObjectRow) -> ContinuityReviewObj "capture_event_id": str(record["capture_event_id"]), "object_type": record["object_type"], # type: ignore[typeddict-item] "status": record["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(record), "title": record["title"], "body": record["body"], "provenance": record["provenance"], @@ -115,6 +117,9 @@ def _snapshot(record: ContinuityObjectRow) -> JsonObject: "capture_event_id": str(record["capture_event_id"]), "object_type": record["object_type"], "status": record["status"], + "is_preserved": record["is_preserved"], + "is_searchable": record["is_searchable"], + "is_promotable": record["is_promotable"], "title": record["title"], "body": record["body"], "provenance": record["provenance"], @@ -371,6 +376,9 @@ def apply_continuity_correction( capture_event_id=capture_event["id"], object_type=current["object_type"], status="active", + is_preserved=current["is_preserved"], + is_searchable=current["is_searchable"], + is_promotable=current["is_promotable"], title=replacement_title, body=replacement_body, provenance=replacement_provenance_payload, @@ -383,6 +391,9 @@ def apply_continuity_correction( updated = store.update_continuity_object_optional( continuity_object_id=continuity_object_id, status="superseded", + is_preserved=current["is_preserved"], + is_searchable=current["is_searchable"], + is_promotable=current["is_promotable"], title=current["title"], body=current["body"], provenance=current["provenance"], @@ -432,6 +443,9 @@ def apply_continuity_correction( updated = store.update_continuity_object_optional( continuity_object_id=continuity_object_id, status=next_status, + is_preserved=current["is_preserved"], + is_searchable=current["is_searchable"], + is_promotable=current["is_promotable"], title=next_title, body=next_body, provenance=next_provenance, diff --git a/apps/api/src/alicebot_api/contracts.py b/apps/api/src/alicebot_api/contracts.py index 299c582..f72dca7 100644 --- a/apps/api/src/alicebot_api/contracts.py +++ b/apps/api/src/alicebot_api/contracts.py @@ -31,6 +31,9 @@ "human_curated", ] MemoryPromotionEligibility = Literal["promotable", "not_promotable"] +ContinuityPreservationStatus = Literal["preserved", "not_preserved"] +ContinuitySearchabilityStatus = Literal["searchable", "not_searchable"] +ContinuityPromotionStatus = Literal["promotable", "not_promotable"] ContinuityRecallFreshnessPosture = Literal["fresh", "aging", "stale", "superseded", "unknown"] ContinuityRecallProvenancePosture = Literal["strong", "partial", "weak", "missing"] ContinuityRecallSupersessionPosture = Literal["current", "historical", "superseded", "deleted"] @@ -461,6 +464,8 @@ DEFAULT_MEMORY_CONFIRMATION_STATUS: MemoryConfirmationStatus = "unconfirmed" DEFAULT_MEMORY_TRUST_CLASS: MemoryTrustClass = "deterministic" DEFAULT_MEMORY_PROMOTION_ELIGIBILITY: MemoryPromotionEligibility = "promotable" +DEFAULT_CONTINUITY_LIFECYCLE_LIMIT = 50 +MAX_CONTINUITY_LIFECYCLE_LIMIT = 200 ENTITY_TYPES = [ "person", "merchant", @@ -563,6 +568,7 @@ CONTINUITY_REVIEW_QUEUE_ORDER = ["updated_at_desc", "created_at_desc", "id_desc"] CONTINUITY_CORRECTION_EVENT_ORDER = ["created_at_desc", "id_desc"] CONTINUITY_RECALL_LIST_ORDER = ["relevance_desc", "created_at_desc", "id_desc"] +CONTINUITY_LIFECYCLE_LIST_ORDER = ["updated_at_desc", "id_desc"] CONTINUITY_RESUMPTION_RECENT_CHANGE_ORDER = ["created_at_desc", "id_desc"] CONTINUITY_RESUMPTION_OPEN_LOOP_ORDER = ["created_at_desc", "id_desc"] CONTINUITY_OPEN_LOOP_POSTURE_ORDER = ["waiting_for", "blocker", "stale", "next_action"] @@ -736,6 +742,18 @@ "supersede", "mark_stale", ] +CONTINUITY_PRESERVATION_STATUSES = [ + "preserved", + "not_preserved", +] +CONTINUITY_SEARCHABILITY_STATUSES = [ + "searchable", + "not_searchable", +] +CONTINUITY_PROMOTION_STATUSES = [ + "promotable", + "not_promotable", +] CONTINUITY_REVIEW_STATUSES = [ "active", "stale", @@ -1689,6 +1707,7 @@ class ContinuityResumptionBriefRequestInput: until: datetime | None = None max_recent_changes: int = DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT max_open_loops: int = DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT + include_non_promotable_facts: bool = False def as_payload(self) -> JsonObject: payload: JsonObject = { @@ -1699,12 +1718,23 @@ def as_payload(self) -> JsonObject: "person": self.person, "max_recent_changes": self.max_recent_changes, "max_open_loops": self.max_open_loops, + "include_non_promotable_facts": self.include_non_promotable_facts, } payload["since"] = isoformat_or_none(self.since) payload["until"] = isoformat_or_none(self.until) return payload +@dataclass(frozen=True, slots=True) +class ContinuityLifecycleQueryInput: + limit: int = DEFAULT_CONTINUITY_LIFECYCLE_LIMIT + + def as_payload(self) -> JsonObject: + return { + "limit": self.limit, + } + + @dataclass(frozen=True, slots=True) class ContinuityOpenLoopDashboardQueryInput: query: str | None = None @@ -2427,11 +2457,21 @@ class ContinuityCaptureEventRecord(TypedDict): created_at: str +class ContinuityLifecycleStateRecord(TypedDict): + is_preserved: bool + preservation_status: ContinuityPreservationStatus + is_searchable: bool + searchability_status: ContinuitySearchabilityStatus + is_promotable: bool + promotion_status: ContinuityPromotionStatus + + class ContinuityObjectRecord(TypedDict): id: str capture_event_id: str object_type: ContinuityObjectType status: str + lifecycle: ContinuityLifecycleStateRecord title: str body: JsonObject provenance: JsonObject @@ -2445,6 +2485,7 @@ class ContinuityReviewObjectRecord(TypedDict): capture_event_id: str object_type: ContinuityObjectType status: str + lifecycle: ContinuityLifecycleStateRecord title: str body: JsonObject provenance: JsonObject @@ -2561,6 +2602,7 @@ class ContinuityRecallResultRecord(TypedDict): capture_event_id: str object_type: ContinuityObjectType status: str + lifecycle: ContinuityLifecycleStateRecord title: str body: JsonObject provenance: JsonObject @@ -2592,6 +2634,31 @@ class ContinuityRecallResponse(TypedDict): summary: ContinuityRecallSummary +class ContinuityLifecycleCounts(TypedDict): + preserved_count: int + searchable_count: int + promotable_count: int + not_searchable_count: int + not_promotable_count: int + + +class ContinuityLifecycleListSummary(TypedDict): + limit: int + returned_count: int + total_count: int + counts: ContinuityLifecycleCounts + order: list[str] + + +class ContinuityLifecycleListResponse(TypedDict): + items: list[ContinuityReviewObjectRecord] + summary: ContinuityLifecycleListSummary + + +class ContinuityLifecycleDetailResponse(TypedDict): + continuity_object: ContinuityReviewObjectRecord + + class ContinuityResumptionEmptyState(TypedDict): is_empty: bool message: str diff --git a/apps/api/src/alicebot_api/main.py b/apps/api/src/alicebot_api/main.py index e8e6c7c..c03724b 100644 --- a/apps/api/src/alicebot_api/main.py +++ b/apps/api/src/alicebot_api/main.py @@ -47,6 +47,7 @@ class RedisError(Exception): DEFAULT_AGENT_PROFILE_ID, DEFAULT_CALENDAR_EVENT_LIST_LIMIT, DEFAULT_CONTINUITY_CAPTURE_LIMIT, + DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, DEFAULT_CONTINUITY_REVIEW_LIMIT, DEFAULT_CONTINUITY_RECALL_LIMIT, DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, @@ -75,6 +76,7 @@ class RedisError(Exception): MAX_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, MAX_CALENDAR_EVENT_LIST_LIMIT, MAX_CONTINUITY_CAPTURE_LIMIT, + MAX_CONTINUITY_LIFECYCLE_LIMIT, MAX_CONTINUITY_REVIEW_LIMIT, MAX_CONTINUITY_RECALL_LIMIT, MAX_CONTINUITY_OPEN_LOOP_LIMIT, @@ -86,6 +88,9 @@ class RedisError(Exception): MAX_SEMANTIC_MEMORY_RETRIEVAL_LIMIT, ContextCompilerLimits, ContinuityCaptureCreateInput, + ContinuityLifecycleDetailResponse, + ContinuityLifecycleListResponse, + ContinuityLifecycleQueryInput, ContinuityDailyBriefRequestInput, ContinuityDailyBriefResponse, ContinuityOpenLoopDashboardQueryInput, @@ -348,6 +353,12 @@ class RedisError(Exception): get_continuity_capture_detail, list_continuity_capture_inbox, ) +from alicebot_api.continuity_lifecycle import ( + ContinuityLifecycleNotFoundError, + ContinuityLifecycleValidationError, + get_continuity_lifecycle_state, + list_continuity_lifecycle_state, +) from alicebot_api.continuity_recall import ( ContinuityRecallValidationError, query_continuity_recall, @@ -4095,6 +4106,56 @@ def get_continuity_capture(capture_event_id: UUID, user_id: UUID) -> JSONRespons ) +@app.get("/v0/admin/debug/continuity/lifecycle") +def list_continuity_lifecycle_endpoint( + user_id: UUID, + limit: int = Query( + default=DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, + ge=1, + le=MAX_CONTINUITY_LIFECYCLE_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityLifecycleListResponse = list_continuity_lifecycle_state( + ContinuityStore(conn), + user_id=user_id, + request=ContinuityLifecycleQueryInput(limit=limit), + ) + except ContinuityLifecycleValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/admin/debug/continuity/lifecycle/{continuity_object_id}") +def get_continuity_lifecycle_endpoint( + continuity_object_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityLifecycleDetailResponse = get_continuity_lifecycle_state( + ContinuityStore(conn), + user_id=user_id, + continuity_object_id=continuity_object_id, + ) + except ContinuityLifecycleNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + @app.get("/v0/continuity/review-queue") def list_continuity_review_queue_endpoint( user_id: UUID, @@ -4429,6 +4490,7 @@ def get_continuity_resumption_brief( ge=0, le=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, ), + include_non_promotable_facts: bool = False, ) -> JSONResponse: settings = get_settings() @@ -4447,6 +4509,7 @@ def get_continuity_resumption_brief( until=until, max_recent_changes=max_recent_changes, max_open_loops=max_open_loops, + include_non_promotable_facts=include_non_promotable_facts, ), ) except ContinuityResumptionValidationError as exc: diff --git a/apps/api/src/alicebot_api/mcp_tools.py b/apps/api/src/alicebot_api/mcp_tools.py index 349cd30..583fcf1 100644 --- a/apps/api/src/alicebot_api/mcp_tools.py +++ b/apps/api/src/alicebot_api/mcp_tools.py @@ -198,6 +198,19 @@ def _parse_optional_float(arguments: Mapping[str, object], key: str) -> float | raise MCPToolError(f"{key} must be a number") +def _parse_bool(arguments: Mapping[str, object], *, key: str, default: bool = False) -> bool: + value = arguments.get(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().casefold() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no"}: + return False + raise MCPToolError(f"{key} must be a boolean") + + def _build_recall_query(arguments: Mapping[str, object], *, limit: int) -> ContinuityRecallQueryInput: return ContinuityRecallQueryInput( query=_parse_optional_text(arguments, "query"), @@ -291,6 +304,11 @@ def _handle_alice_resume(context: MCPRuntimeContext, arguments: Mapping[str, obj until=_parse_optional_datetime(arguments, "until"), max_recent_changes=max_recent_changes, max_open_loops=max_open_loops, + include_non_promotable_facts=_parse_bool( + arguments, + key="include_non_promotable_facts", + default=False, + ), ), ) @@ -588,6 +606,7 @@ def _handle_alice_context_pack(context: MCPRuntimeContext, arguments: Mapping[st "minimum": 0, "maximum": MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, }, + "include_non_promotable_facts": {"type": "boolean"}, }, }, }, diff --git a/apps/api/src/alicebot_api/store.py b/apps/api/src/alicebot_api/store.py index af4e641..e5c850f 100644 --- a/apps/api/src/alicebot_api/store.py +++ b/apps/api/src/alicebot_api/store.py @@ -170,6 +170,9 @@ class ContinuityObjectRow(TypedDict): capture_event_id: UUID object_type: str status: str + is_preserved: bool + is_searchable: bool + is_promotable: bool title: str body: JsonObject provenance: JsonObject @@ -199,6 +202,9 @@ class ContinuityRecallCandidateRow(TypedDict): capture_event_id: UUID object_type: str status: str + is_preserved: bool + is_searchable: bool + is_promotable: bool title: str body: JsonObject provenance: JsonObject @@ -3926,6 +3932,9 @@ class LabelCountRow(TypedDict): capture_event_id, object_type, status, + is_preserved, + is_searchable, + is_promotable, title, body, provenance, @@ -3945,6 +3954,9 @@ class LabelCountRow(TypedDict): %s, %s, %s, + %s, + %s, + %s, %s ) RETURNING @@ -3953,6 +3965,9 @@ class LabelCountRow(TypedDict): capture_event_id, object_type, status, + is_preserved, + is_searchable, + is_promotable, title, body, provenance, @@ -3971,6 +3986,9 @@ class LabelCountRow(TypedDict): capture_event_id, object_type, status, + is_preserved, + is_searchable, + is_promotable, title, body, provenance, @@ -3991,6 +4009,9 @@ class LabelCountRow(TypedDict): capture_event_id, object_type, status, + is_preserved, + is_searchable, + is_promotable, title, body, provenance, @@ -4011,6 +4032,9 @@ class LabelCountRow(TypedDict): capture_event_id, object_type, status, + is_preserved, + is_searchable, + is_promotable, title, body, provenance, @@ -4032,6 +4056,9 @@ class LabelCountRow(TypedDict): capture_event_id, object_type, status, + is_preserved, + is_searchable, + is_promotable, title, body, provenance, @@ -4060,6 +4087,9 @@ class LabelCountRow(TypedDict): continuity_objects.capture_event_id, continuity_objects.object_type, continuity_objects.status, + continuity_objects.is_preserved, + continuity_objects.is_searchable, + continuity_objects.is_promotable, continuity_objects.title, continuity_objects.body, continuity_objects.provenance, @@ -4083,6 +4113,9 @@ class LabelCountRow(TypedDict): UPDATE_CONTINUITY_OBJECT_SQL = """ UPDATE continuity_objects SET status = %s, + is_preserved = %s, + is_searchable = %s, + is_promotable = %s, title = %s, body = %s, provenance = %s, @@ -4098,6 +4131,9 @@ class LabelCountRow(TypedDict): capture_event_id, object_type, status, + is_preserved, + is_searchable, + is_promotable, title, body, provenance, @@ -4175,6 +4211,14 @@ class ContinuityStore: def __init__(self, conn: psycopg.Connection): self.conn = conn + @staticmethod + def _default_continuity_searchable(object_type: str) -> bool: + return object_type != "Note" + + @staticmethod + def _default_continuity_promotable(object_type: str) -> bool: + return object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"} + def _acquire_advisory_lock(self, lock_query: str, lock_key: UUID) -> None: with self.conn.cursor() as cur: cur.execute(lock_query, (str(lock_key),)) @@ -4686,10 +4730,24 @@ def create_continuity_object( body: JsonObject, provenance: JsonObject, confidence: float, + is_preserved: bool | None = None, + is_searchable: bool | None = None, + is_promotable: bool | None = None, last_confirmed_at: datetime | None = None, supersedes_object_id: UUID | None = None, superseded_by_object_id: UUID | None = None, ) -> ContinuityObjectRow: + resolved_is_preserved = True if is_preserved is None else is_preserved + resolved_is_searchable = ( + self._default_continuity_searchable(object_type) + if is_searchable is None + else is_searchable + ) + resolved_is_promotable = ( + self._default_continuity_promotable(object_type) + if is_promotable is None + else is_promotable + ) return self._fetch_one( "create_continuity_object", INSERT_CONTINUITY_OBJECT_SQL, @@ -4697,6 +4755,9 @@ def create_continuity_object( capture_event_id, object_type, status, + resolved_is_preserved, + resolved_is_searchable, + resolved_is_promotable, title, Jsonb(body), Jsonb(provenance), @@ -4765,6 +4826,9 @@ def update_continuity_object_optional( *, continuity_object_id: UUID, status: str, + is_preserved: bool, + is_searchable: bool, + is_promotable: bool, title: str, body: JsonObject, provenance: JsonObject, @@ -4777,6 +4841,9 @@ def update_continuity_object_optional( UPDATE_CONTINUITY_OBJECT_SQL, ( status, + is_preserved, + is_searchable, + is_promotable, title, Jsonb(body), Jsonb(provenance), diff --git a/tests/integration/test_cli_integration.py b/tests/integration/test_cli_integration.py index 3b1e4fc..0090eec 100644 --- a/tests/integration/test_cli_integration.py +++ b/tests/integration/test_cli_integration.py @@ -98,6 +98,7 @@ def test_cli_command_surface_and_correction_flow(migrated_database_urls) -> None assert status_result.returncode == 0 assert "database: reachable" in status_result.stdout assert "continuity_capture_events: 2" in status_result.stdout + assert "continuity_object_lifecycle:" in status_result.stdout capture_result = run_cli( [ @@ -126,6 +127,22 @@ def test_cli_command_surface_and_correction_flow(migrated_database_urls) -> None ) assert recall_before.returncode == 0 assert "Decision: Legacy rollout plan" in recall_before.stdout + assert "lifecycle=preserved:True searchable:True promotable:True" in recall_before.stdout + + lifecycle_list_result = run_cli( + ["lifecycle", "list", "--limit", "20"], + env=env, + ) + assert lifecycle_list_result.returncode == 0 + assert "continuity lifecycle" in lifecycle_list_result.stdout + assert "promotable=3" in lifecycle_list_result.stdout + + lifecycle_show_result = run_cli( + ["lifecycle", "show", str(legacy_decision["id"])], + env=env, + ) + assert lifecycle_show_result.returncode == 0 + assert f"continuity_object_id: {legacy_decision['id']}" in lifecycle_show_result.stdout resume_before = run_cli( [ @@ -220,3 +237,59 @@ def test_cli_command_surface_and_correction_flow(migrated_database_urls) -> None assert resume_after.returncode == 0 assert "Decision: Updated rollout plan" in resume_after.stdout + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + hidden_fact_capture = store.create_continuity_capture_event( + raw_content="Remember: searchable but not promotable", + explicit_signal="remember_this", + admission_posture="DERIVED", + admission_reason="explicit_signal_remember_this", + ) + hidden_fact = store.create_continuity_object( + capture_event_id=hidden_fact_capture["id"], + object_type="MemoryFact", + status="active", + title="Memory Fact: searchable but not promotable", + body={"fact_text": "searchable but not promotable"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["cli-seed-3"]}, + confidence=0.88, + is_promotable=False, + ) + + lifecycle_hidden_result = run_cli( + ["lifecycle", "show", str(hidden_fact["id"])], + env=env, + ) + assert lifecycle_hidden_result.returncode == 0 + assert "promotable=False" in lifecycle_hidden_result.stdout + + resume_default_fact_result = run_cli( + [ + "resume", + "--thread-id", + str(thread_id), + "--max-recent-changes", + "10", + "--max-open-loops", + "5", + ], + env=env, + ) + assert resume_default_fact_result.returncode == 0 + assert "Memory Fact: searchable but not promotable" not in resume_default_fact_result.stdout + + resume_override_fact_result = run_cli( + [ + "resume", + "--thread-id", + str(thread_id), + "--max-recent-changes", + "10", + "--max-open-loops", + "5", + "--include-non-promotable-facts", + ], + env=env, + ) + assert resume_override_fact_result.returncode == 0 + assert "Memory Fact: searchable but not promotable" in resume_override_fact_result.stdout diff --git a/tests/integration/test_continuity_recall_api.py b/tests/integration/test_continuity_recall_api.py index 742e4d8..481124c 100644 --- a/tests/integration/test_continuity_recall_api.py +++ b/tests/integration/test_continuity_recall_api.py @@ -85,6 +85,33 @@ def set_continuity_timestamps( ) +def set_continuity_lifecycle_flags( + admin_database_url: str, + *, + continuity_object_id: UUID, + is_searchable: bool | None = None, + is_promotable: bool | None = None, +) -> None: + assignments: list[str] = [] + values: list[object] = [] + if is_searchable is not None: + assignments.append("is_searchable = %s") + values.append(is_searchable) + if is_promotable is not None: + assignments.append("is_promotable = %s") + values.append(is_promotable) + if not assignments: + return + + values.append(continuity_object_id) + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE continuity_objects SET {', '.join(assignments)} WHERE id = %s", + tuple(values), + ) + + def test_continuity_recall_api_returns_provenance_backed_scoped_results( migrated_database_urls, monkeypatch, @@ -354,3 +381,142 @@ def test_continuity_recall_api_prefers_confirmed_fresh_active_truth_over_superse assert payload["items"][0]["ordering"]["freshness_posture"] == "fresh" assert payload["items"][0]["ordering"]["supersession_posture"] == "current" assert payload["items"][-1]["ordering"]["supersession_posture"] == "superseded" + + +def test_continuity_recall_api_excludes_preserved_but_non_searchable_objects( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="nonsearchable@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + hidden_capture = store.create_continuity_capture_event( + raw_content="Note: internal scratchpad", + explicit_signal="note", + admission_posture="DERIVED", + admission_reason="explicit_signal_note", + ) + hidden_object = store.create_continuity_object( + capture_event_id=hidden_capture["id"], + object_type="Note", + status="active", + title="Note: internal scratchpad", + body={"body": "internal scratchpad"}, + provenance={}, + confidence=1.0, + ) + visible_capture = store.create_continuity_capture_event( + raw_content="Decision: public outcome", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + visible_object = store.create_continuity_object( + capture_event_id=visible_capture["id"], + object_type="Decision", + status="active", + title="Decision: public outcome", + body={"decision_text": "public outcome"}, + provenance={}, + confidence=1.0, + ) + + set_continuity_lifecycle_flags( + migrated_database_urls["admin"], + continuity_object_id=hidden_object["id"], + is_searchable=False, + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=hidden_object["id"], + created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=visible_object["id"], + created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/recall", + query_params={ + "user_id": str(user_id), + "limit": "20", + }, + ) + + assert status == 200 + assert [item["title"] for item in payload["items"]] == ["Decision: public outcome"] + + +def test_continuity_lifecycle_debug_endpoints_expose_flags( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="lifecycle-debug@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Remember: searchable but not promotable", + explicit_signal="remember_this", + admission_posture="DERIVED", + admission_reason="explicit_signal_remember_this", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="MemoryFact", + status="active", + title="Memory Fact: searchable but not promotable", + body={"fact_text": "searchable but not promotable"}, + provenance={}, + confidence=0.9, + ) + + set_continuity_lifecycle_flags( + migrated_database_urls["admin"], + continuity_object_id=continuity_object["id"], + is_promotable=False, + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/admin/debug/continuity/lifecycle", + query_params={"user_id": str(user_id), "limit": "20"}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/admin/debug/continuity/lifecycle/{continuity_object['id']}", + query_params={"user_id": str(user_id)}, + ) + + assert list_status == 200 + assert list_payload["summary"]["counts"]["preserved_count"] == 1 + assert list_payload["summary"]["counts"]["searchable_count"] == 1 + assert list_payload["summary"]["counts"]["promotable_count"] == 0 + assert list_payload["items"][0]["lifecycle"] == { + "is_preserved": True, + "preservation_status": "preserved", + "is_searchable": True, + "searchability_status": "searchable", + "is_promotable": False, + "promotion_status": "not_promotable", + } + + assert detail_status == 200 + assert detail_payload["continuity_object"]["id"] == str(continuity_object["id"]) + assert detail_payload["continuity_object"]["lifecycle"]["is_promotable"] is False diff --git a/tests/integration/test_continuity_resumption_api.py b/tests/integration/test_continuity_resumption_api.py index 408d1be..9c06bc8 100644 --- a/tests/integration/test_continuity_resumption_api.py +++ b/tests/integration/test_continuity_resumption_api.py @@ -85,6 +85,20 @@ def set_continuity_timestamps( ) +def set_continuity_lifecycle_flags( + admin_database_url: str, + *, + continuity_object_id: UUID, + is_promotable: bool, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET is_promotable = %s WHERE id = %s", + (is_promotable, continuity_object_id), + ) + + def test_continuity_resumption_api_returns_required_sections( migrated_database_urls, monkeypatch, @@ -390,3 +404,99 @@ def test_continuity_resumption_api_selects_latest_sections_beyond_recall_limit( "Next Action: newest low confidence", "Decision: newest low confidence", ] + + +def test_continuity_resumption_api_uses_promotable_facts_by_default_with_override( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="promotable@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + fact_capture = store.create_continuity_capture_event( + raw_content="Remember: hidden from brief", + explicit_signal="remember_this", + admission_posture="DERIVED", + admission_reason="explicit_signal_remember_this", + ) + fact_object = store.create_continuity_object( + capture_event_id=fact_capture["id"], + object_type="MemoryFact", + status="active", + title="Memory Fact: hidden from brief", + body={"fact_text": "hidden from brief"}, + provenance={"thread_id": str(thread_id)}, + confidence=0.9, + ) + decision_capture = store.create_continuity_capture_event( + raw_content="Decision: visible in brief", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + decision_object = store.create_continuity_object( + capture_event_id=decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: visible in brief", + body={"decision_text": "visible in brief"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + set_continuity_lifecycle_flags( + migrated_database_urls["admin"], + continuity_object_id=fact_object["id"], + is_promotable=False, + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=fact_object["id"], + created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=decision_object["id"], + created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + default_status, default_payload = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_recent_changes": "5", + "max_open_loops": "2", + }, + ) + override_status, override_payload = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_recent_changes": "5", + "max_open_loops": "2", + "include_non_promotable_facts": "true", + }, + ) + + assert default_status == 200 + assert [item["title"] for item in default_payload["brief"]["recent_changes"]["items"]] == [ + "Decision: visible in brief", + ] + + assert override_status == 200 + assert [item["title"] for item in override_payload["brief"]["recent_changes"]["items"]] == [ + "Decision: visible in brief", + "Memory Fact: hidden from brief", + ] diff --git a/tests/unit/test_20260410_0049_continuity_object_lifecycle_flags.py b/tests/unit/test_20260410_0049_continuity_object_lifecycle_flags.py new file mode 100644 index 0000000..36ad518 --- /dev/null +++ b/tests/unit/test_20260410_0049_continuity_object_lifecycle_flags.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260410_0049_continuity_object_lifecycle_flags" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) diff --git a/tests/unit/test_chief_of_staff.py b/tests/unit/test_chief_of_staff.py index 3c60aca..6f60c77 100644 --- a/tests/unit/test_chief_of_staff.py +++ b/tests/unit/test_chief_of_staff.py @@ -789,12 +789,18 @@ def create_continuity_object( body: dict[str, object], provenance: dict[str, object], confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, ) -> dict[str, object]: self.object_payloads.append( { "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, @@ -920,12 +926,18 @@ def create_continuity_object( body: dict[str, object], provenance: dict[str, object], confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, ) -> dict[str, object]: self.object_payloads.append( { "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, @@ -1305,12 +1317,18 @@ def create_continuity_object( body: dict[str, object], provenance: dict[str, object], confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, ) -> dict[str, object]: self.object_payloads.append( { "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, @@ -1508,12 +1526,18 @@ def create_continuity_object( body: dict[str, object], provenance: dict[str, object], confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, ) -> dict[str, object]: self.object_payloads.append( { "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 3e12d6a..cffe06a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -14,6 +14,8 @@ def test_parser_routes_required_commands() -> None: cases = [ (["capture", "Decision: Keep rollout phased"], "_run_capture"), (["recall"], "_run_recall"), + (["lifecycle", "list"], "_run_lifecycle_list"), + (["lifecycle", "show", continuity_object_id], "_run_lifecycle_show"), (["resume"], "_run_resume"), (["open-loops"], "_run_open_loops"), (["review", "queue"], "_run_review_queue"), @@ -77,6 +79,14 @@ def test_recall_formatting_is_deterministic() -> None: "capture_event_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "object_type": "Decision", "status": "active", + "lifecycle": { + "is_preserved": True, + "preservation_status": "preserved", + "is_searchable": True, + "searchability_status": "searchable", + "is_promotable": True, + "promotion_status": "promotable", + }, "title": "Decision: Keep rollout phased", "body": {"decision_text": "Keep rollout phased"}, "provenance": {"thread_id": "thread-1"}, @@ -131,6 +141,7 @@ def test_recall_formatting_is_deterministic() -> None: "items:\n" " 1. [Decision|active] Decision: Keep rollout phased\n" " id=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa capture_event_id=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\n" + " lifecycle=preserved:True searchable:True promotable:True\n" " confidence=0.950 relevance=1.000 confirmation=confirmed\n" " freshness=fresh provenance=strong supersession=current\n" " source=(unknown)\n" @@ -167,6 +178,14 @@ def test_recall_formatting_renders_provenance_source_label_when_present() -> Non "capture_event_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "object_type": "Decision", "status": "active", + "lifecycle": { + "is_preserved": True, + "preservation_status": "preserved", + "is_searchable": True, + "searchability_status": "searchable", + "is_promotable": True, + "promotion_status": "promotable", + }, "title": "Decision: Keep rollout phased", "body": {"decision_text": "Keep rollout phased"}, "provenance": {"source_kind": "openclaw_import", "source_label": "OpenClaw"}, diff --git a/tests/unit/test_continuity_capture.py b/tests/unit/test_continuity_capture.py index 274c3b5..80126f4 100644 --- a/tests/unit/test_continuity_capture.py +++ b/tests/unit/test_continuity_capture.py @@ -67,6 +67,9 @@ def create_continuity_object( body, provenance, confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, ): row = { "id": uuid4(), @@ -74,6 +77,9 @@ def create_continuity_object( "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, diff --git a/tests/unit/test_continuity_objects.py b/tests/unit/test_continuity_objects.py index bfd8d81..94bd130 100644 --- a/tests/unit/test_continuity_objects.py +++ b/tests/unit/test_continuity_objects.py @@ -29,6 +29,9 @@ def create_continuity_object( body, provenance, confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, ): created = { "id": uuid4(), @@ -36,6 +39,9 @@ def create_continuity_object( "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, @@ -79,6 +85,14 @@ def test_create_continuity_object_record_serializes_created_row() -> None: "capture_event_id": str(capture_event_id), "object_type": "Decision", "status": "active", + "lifecycle": { + "is_preserved": True, + "preservation_status": "preserved", + "is_searchable": True, + "searchability_status": "searchable", + "is_promotable": True, + "promotion_status": "promotable", + }, "title": "Decision: Use bounded intake", "body": {"decision_text": "Use bounded intake"}, "provenance": {"capture_event_id": str(capture_event_id)}, diff --git a/tests/unit/test_continuity_open_loops.py b/tests/unit/test_continuity_open_loops.py index f83ecd3..ab38e1a 100644 --- a/tests/unit/test_continuity_open_loops.py +++ b/tests/unit/test_continuity_open_loops.py @@ -47,6 +47,9 @@ def add_object( "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": True, + "is_searchable": True, + "is_promotable": True, "title": title, "body": {"text": title}, "provenance": {"thread_id": "thread-1"}, @@ -104,6 +107,9 @@ def update_continuity_object_optional( *, continuity_object_id: UUID, status: str, + is_preserved: bool, + is_searchable: bool, + is_promotable: bool, title: str, body, provenance, @@ -119,6 +125,9 @@ def update_continuity_object_optional( updated = { **row, "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, @@ -145,6 +154,9 @@ def make_candidate_row( "capture_event_id": uuid4(), "object_type": object_type, "status": status, + "is_preserved": True, + "is_searchable": True, + "is_promotable": object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"}, "title": title, "body": {"text": title}, "provenance": {"thread_id": "thread-1"}, diff --git a/tests/unit/test_continuity_recall.py b/tests/unit/test_continuity_recall.py index 947db90..6781d2e 100644 --- a/tests/unit/test_continuity_recall.py +++ b/tests/unit/test_continuity_recall.py @@ -30,17 +30,27 @@ def make_candidate_row( last_confirmed_at: datetime | None = None, supersedes_object_id: UUID | None = None, superseded_by_object_id: UUID | None = None, + is_searchable: bool = True, + is_promotable: bool | None = None, ) -> dict[str, object]: object_id = uuid4() capture_event_id = uuid4() created_at = capture_created_at updated_at = capture_created_at + resolved_is_promotable = ( + object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"} + if is_promotable is None + else is_promotable + ) return { "id": object_id, "user_id": UUID("11111111-1111-4111-8111-111111111111"), "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": True, + "is_searchable": is_searchable, + "is_promotable": resolved_is_promotable, "title": title, "body": body or {}, "provenance": provenance or {}, @@ -135,6 +145,7 @@ def test_recall_returns_deterministic_order_and_provenance_fields() -> None: assert payload["items"][0]["ordering"]["supersession_posture"] == "current" assert payload["items"][0]["ordering"]["supersession_rank"] == 3 assert payload["items"][0]["ordering"]["lifecycle_rank"] == 4 + assert payload["items"][0]["lifecycle"]["is_promotable"] is True def test_recall_filters_project_person_query_and_time_window() -> None: @@ -220,6 +231,16 @@ def test_recall_excludes_deleted_and_ranks_lifecycle_posture_deterministically() status="deleted", body={"decision_text": "deleted item"}, ), + make_candidate_row( + title="Note: preserved but hidden", + object_type="Note", + capture_created_at=datetime(2026, 3, 29, 10, 4, tzinfo=UTC), + confidence=1.0, + status="active", + body={"body": "preserved but hidden"}, + is_searchable=False, + is_promotable=False, + ), ] store = ContinuityRecallStoreStub(rows) # type: ignore[arg-type] @@ -235,6 +256,7 @@ def test_recall_excludes_deleted_and_ranks_lifecycle_posture_deterministically() "Decision: superseded item", ] assert all(item["status"] != "deleted" for item in payload["items"]) + assert all(item["object_type"] != "Note" for item in payload["items"]) with pytest.raises(ContinuityRecallValidationError, match="until must be greater than or equal to since"): query_continuity_recall( diff --git a/tests/unit/test_continuity_resumption.py b/tests/unit/test_continuity_resumption.py index 562148e..25d86db 100644 --- a/tests/unit/test_continuity_resumption.py +++ b/tests/unit/test_continuity_resumption.py @@ -23,13 +23,23 @@ def make_candidate_row( provenance: dict[str, object] | None = None, confidence: float = 1.0, status: str = "active", + is_searchable: bool = True, + is_promotable: bool | None = None, ) -> dict[str, object]: + resolved_is_promotable = ( + object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"} + if is_promotable is None + else is_promotable + ) return { "id": uuid4(), "user_id": UUID("11111111-1111-4111-8111-111111111111"), "capture_event_id": uuid4(), "object_type": object_type, "status": status, + "is_preserved": True, + "is_searchable": is_searchable, + "is_promotable": resolved_is_promotable, "title": title, "body": {"text": title}, "provenance": provenance or {}, @@ -299,3 +309,50 @@ def test_resumption_brief_excludes_completed_and_stale_from_primary_open_loop_se "Waiting For: deferred stale item", "Waiting For: completed item", ] + + +def test_resumption_brief_excludes_non_promotable_memory_facts_by_default_but_can_override() -> None: + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + rows = [ + make_candidate_row( + title="Memory Fact: searchable but not promotable", + object_type="MemoryFact", + capture_created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + is_promotable=False, + ), + make_candidate_row( + title="Decision: still visible", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + ), + ] + + default_payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=5, + max_open_loops=2, + ), + ) + override_payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=5, + max_open_loops=2, + include_non_promotable_facts=True, + ), + ) + + assert [item["title"] for item in default_payload["brief"]["recent_changes"]["items"]] == [ + "Decision: still visible", + ] + assert [item["title"] for item in override_payload["brief"]["recent_changes"]["items"]] == [ + "Decision: still visible", + "Memory Fact: searchable but not promotable", + ] diff --git a/tests/unit/test_continuity_review.py b/tests/unit/test_continuity_review.py index ada9096..c967b55 100644 --- a/tests/unit/test_continuity_review.py +++ b/tests/unit/test_continuity_review.py @@ -40,6 +40,9 @@ def add_object( "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": True, + "is_searchable": True, + "is_promotable": True, "title": title, "body": {"text": title}, "provenance": {"capture_event_id": str(capture_event_id)}, @@ -107,6 +110,9 @@ def update_continuity_object_optional( *, continuity_object_id: UUID, status: str, + is_preserved: bool, + is_searchable: bool, + is_promotable: bool, title: str, body, provenance, @@ -122,6 +128,9 @@ def update_continuity_object_optional( row.update( { "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, @@ -163,6 +172,9 @@ def create_continuity_object( body, provenance, confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, last_confirmed_at: datetime | None = None, supersedes_object_id: UUID | None = None, superseded_by_object_id: UUID | None = None, @@ -175,6 +187,9 @@ def create_continuity_object( "capture_event_id": capture_event_id, "object_type": object_type, "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, "title": title, "body": body, "provenance": provenance, @@ -225,6 +240,7 @@ def test_confirm_records_event_before_lifecycle_mutation() -> None: assert store.call_log[:2] == ["create_event", "update_object"] assert payload["continuity_object"]["status"] == "active" + assert payload["continuity_object"]["lifecycle"]["is_promotable"] is True assert payload["continuity_object"]["last_confirmed_at"] is not None assert payload["correction_event"]["action"] == "confirm" assert payload["replacement_object"] is None diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 137d0c3..6fe92fe 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -132,6 +132,8 @@ def test_healthcheck_route_is_registered() -> None: assert "/v0/memory-embeddings" in route_paths assert "/v0/memories/{memory_id}/embeddings" in route_paths assert "/v0/memory-embeddings/{memory_embedding_id}" in route_paths + assert "/v0/admin/debug/continuity/lifecycle" in route_paths + assert "/v0/admin/debug/continuity/lifecycle/{continuity_object_id}" in route_paths assert "/v0/task-artifact-chunk-embeddings" in route_paths assert "/v0/task-artifacts/{task_artifact_id}/chunk-embeddings" in route_paths assert "/v0/task-artifact-chunks/{task_artifact_chunk_id}/embeddings" in route_paths