From 96ae01b780189ce933c5682a918d2602b801e7a8 Mon Sep 17 00:00:00 2001 From: RightL Date: Fri, 28 Aug 2026 09:14:43 +0800 Subject: [PATCH 1/3] Implement a human-owned Pursuit map Keep Pursuit in canonical Markdown while exposing its logical tree through the existing Web Studio. Add direct map editing, automatic backing files, ordered optimistic saves, and validated Git undo/redo with session and revision checks. Block Update from changing Pursuit at both tool and candidate-commit boundaries. Keep sync repair available and allow only exact reference repair in Memory for map deletions. Preserve physical bytes or refuse unsafe Git checkout conversion. Ship the locked Mind Elixir frontend and compiled assets in the Python package, add the explicit map-maintenance skill, update current product rules and docs, and verify storage, API, installer, browser, and Windows process behavior. --- .gitattributes | 3 + DESIGN_NOTES.md | 33 + PURSUITS.example.md | 22 +- README.md | 88 ++- docs/DEMO.md | 24 +- docs/MCP.md | 6 +- docs/PURSUIT_MAP.md | 122 ++++ docs/RIGHTMEMORY_MAINTAINER_BACKGROUND.md | 11 +- docs/assets/rightmemory-single-root.svg | 14 +- pyproject.toml | 1 + rightmemory/cli.py | 43 +- rightmemory/graph.py | 146 +++- rightmemory/install_core.py | 23 +- rightmemory/isolated_write.py | 215 +++++- rightmemory/mcp.py | 8 +- rightmemory/prompt.py | 9 +- rightmemory/prompts/reviewer.md | 12 +- rightmemory/prompts/update.md | 12 +- rightmemory/pursuit_store.py | 608 ++++++++++++++++ rightmemory/pursuit_tree.py | 660 ++++++++++++++++++ rightmemory/recent_submitted.py | 2 +- rightmemory/reference/MEMORY_RULES.md | 4 +- rightmemory/reference/PURSUIT_RULES.md | 79 +-- rightmemory/reference/rightmemory-schema.md | 7 +- rightmemory/tools.py | 17 +- rightmemory/web/app.py | 76 +- rightmemory/web/frontend/.gitignore | 2 + rightmemory/web/frontend/build.mjs | 27 + rightmemory/web/frontend/package-lock.json | 598 ++++++++++++++++ rightmemory/web/frontend/package.json | 20 + rightmemory/web/frontend/src/drafts.ts | 72 ++ rightmemory/web/frontend/src/pursuit-map.css | 185 +++++ rightmemory/web/frontend/src/pursuit-map.ts | 519 ++++++++++++++ rightmemory/web/frontend/src/queue.ts | 160 +++++ rightmemory/web/frontend/src/renderer.ts | 311 +++++++++ rightmemory/web/frontend/src/tree.ts | 164 +++++ rightmemory/web/frontend/src/view-state.ts | 116 +++ rightmemory/web/frontend/tests/browser.html | 13 + rightmemory/web/frontend/tests/browser.ts | 49 ++ rightmemory/web/frontend/tests/fixtures.ts | 31 + rightmemory/web/frontend/tests/queue.test.ts | 118 ++++ rightmemory/web/frontend/tests/tree.test.ts | 69 ++ .../web/frontend/tests/view-state.test.ts | 90 +++ rightmemory/web/frontend/tsconfig.json | 14 + rightmemory/web/process.py | 128 +++- rightmemory/web/service.py | 27 + rightmemory/web/static/app.js | 40 +- rightmemory/web/static/index.html | 3 + .../web/static/pursuit-map.LICENSE.txt | 24 + rightmemory/web/static/pursuit-map.css | 1 + rightmemory/web/static/pursuit-map.js | 73 ++ rightmemory/web/static/styles.css | 7 + skills/maintain-pursuit-map/SKILL.md | 18 + skills/maintain-rightmemory/SKILL.md | 8 +- .../SKILL.md | 10 +- skills/rightmemory-orchestrator-cli/SKILL.md | 7 +- tests/asgi_client.py | 5 +- tests/test_async_update.py | 6 +- tests/test_graph.py | 77 +- tests/test_guidance.py | 6 +- tests/test_install.py | 21 +- tests/test_install_core.py | 90 +-- tests/test_install_windows.py | 22 +- ...est_isolated_write_candidate_validation.py | 323 ++++++++- ...test_isolated_write_update_finalization.py | 16 +- tests/test_pursuit_store.py | 454 ++++++++++++ tests/test_pursuit_tree.py | 315 +++++++++ tests/test_pursuit_web.py | 345 +++++++++ tests/test_tools.py | 119 +++- tests/test_web_cli.py | 345 ++++++++- tests/test_windows_process_integration.py | 79 ++- 71 files changed, 7065 insertions(+), 307 deletions(-) create mode 100644 .gitattributes create mode 100644 DESIGN_NOTES.md create mode 100644 docs/PURSUIT_MAP.md create mode 100644 rightmemory/pursuit_store.py create mode 100644 rightmemory/pursuit_tree.py create mode 100644 rightmemory/web/frontend/.gitignore create mode 100644 rightmemory/web/frontend/build.mjs create mode 100644 rightmemory/web/frontend/package-lock.json create mode 100644 rightmemory/web/frontend/package.json create mode 100644 rightmemory/web/frontend/src/drafts.ts create mode 100644 rightmemory/web/frontend/src/pursuit-map.css create mode 100644 rightmemory/web/frontend/src/pursuit-map.ts create mode 100644 rightmemory/web/frontend/src/queue.ts create mode 100644 rightmemory/web/frontend/src/renderer.ts create mode 100644 rightmemory/web/frontend/src/tree.ts create mode 100644 rightmemory/web/frontend/src/view-state.ts create mode 100644 rightmemory/web/frontend/tests/browser.html create mode 100644 rightmemory/web/frontend/tests/browser.ts create mode 100644 rightmemory/web/frontend/tests/fixtures.ts create mode 100644 rightmemory/web/frontend/tests/queue.test.ts create mode 100644 rightmemory/web/frontend/tests/tree.test.ts create mode 100644 rightmemory/web/frontend/tests/view-state.test.ts create mode 100644 rightmemory/web/frontend/tsconfig.json create mode 100644 rightmemory/web/static/pursuit-map.LICENSE.txt create mode 100644 rightmemory/web/static/pursuit-map.css create mode 100644 rightmemory/web/static/pursuit-map.js create mode 100644 skills/maintain-pursuit-map/SKILL.md create mode 100644 tests/test_pursuit_store.py create mode 100644 tests/test_pursuit_tree.py create mode 100644 tests/test_pursuit_web.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b54ccb6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Review the TypeScript/CSS sources; these bundles retain dependency string bytes. +rightmemory/web/static/pursuit-map.js linguist-generated=true -whitespace +rightmemory/web/static/pursuit-map.css linguist-generated=true diff --git a/DESIGN_NOTES.md b/DESIGN_NOTES.md new file mode 100644 index 0000000..b0e338c --- /dev/null +++ b/DESIGN_NOTES.md @@ -0,0 +1,33 @@ +# RightMemory Design Notes + +## Human-Owned Pursuit Map + +### Ownership follows the kind of state + +Memory preserves reusable context, and Update can judge whether new evidence improves that context. A Pursuit records a different decision: which directions the user wants to keep visible and how to group them. Task progress cannot reliably establish that decision. Keeping those judgments separate prevents incomplete work, transient blockers, and completed experiments from accumulating as a task ledger inside the map. + +The human editor and explicitly requested `maintain-pursuit-map` workflow own semantic map changes. Other agents may use the map to understand the user's directions without acquiring authority to reorganize them. Update's bounded tools and accepted write surface enforce this distinction in code. Sync still reconciles already-authorized map state, because safe transport needs the complete graph rather than a second ownership model. + +### The tree carries meaning without task fields + +A title, ancestry, and optional Markdown note are enough to describe a direction. A shared ancestor can establish a project's starting document or repository without adding fixed project, person, or file fields. Existing graph edges represent real relationships when tree placement is insufficient. Reusable context remains ordinary Memory, and execution detail remains in project artifacts. + +Completion is a user decision to remove a direction after considering any independently durable consequence. Git supplies the earlier state. A completed-status collection would turn the map into a work log, while a natural branch such as “Later” already expresses a direction the user wants to retain. + +### The visual map is a view of Markdown + +`PURSUITS.md` and reachable `PURSUIT_.md` files remain authoritative. The logical tree comes from the canonical graph index, so retrieval, validation, sync, and editing agree about identity and ancestry. There is no second Pursuit database or task registry. + +The canvas hides physical heading depth. When a deeper logical branch needs a file boundary, `F#` continues that hierarchy in a detail file. Existing boundaries stay stable to avoid unrelated rewrites. Pan, zoom, folding, and selection belong to browser-local view state and do not alter semantic Markdown. + +### A confirmed interaction is the transaction boundary + +Typing and pointer movement are local editing activity. Confirming a rename, structural move, note, deletion, or Focus change creates the candidate state. The store checks the expected revision, validates the complete candidate graph, and lands it only while the active root still matches the captured state. Failure leaves the active root unchanged. + +This allows immediate canvas editing while preserving an inspectable Git change. Undo and redo create compensating commits and reject stale history; they do not reset the shared branch. Deletion can repair incoming typed edges in Memory, but that mechanical repair is not authority to rewrite Memory meaning. + +### Existing data remains readable + +Old Pursuit field blocks are retained as body text so existing roots can open before a separate, explicitly approved cleanup. Their old action labels have no current control semantics. This is a narrow reading accommodation, not a second schema or an automatic migration. Installing or opening the editor does not rewrite a user's root. + +The [Pursuit rules](rightmemory/reference/PURSUIT_RULES.md) own semantic meaning; the [schema](rightmemory/reference/rightmemory-schema.md) owns representation; the [Pursuit Map guide](docs/PURSUIT_MAP.md) covers use and implementation entry points. diff --git a/PURSUITS.example.md b/PURSUITS.example.md index d975cf2..d49b76c 100644 --- a/PURSUITS.example.md +++ b/PURSUITS.example.md @@ -1,6 +1,6 @@ > This starter template is copied into a new RightMemory root during bootstrap. -> Replace the example Pursuit with live intent that should shape later work, and do not -> treat it as user state. +> Replace the example through the map editor or an explicit map-maintenance request. +> It is illustrative, not user state. # Pursuits @@ -8,20 +8,16 @@ No Pursuit is focused yet. -## Example Release Readiness {#sample-pursuit-release-readiness} +## Example Application {#sample-pursuit-application} -Make the example application's current `staging` candidate ready for an intentional -release to `production`. This sample keeps only the objective and current direction; -detailed test output, commands, and execution history belong in project-local -artifacts. +Work starts from the application's repository and its current design document. +The user decides which directions belong in this map. Detailed implementation, +experiments, and test results stay in the repository. -**State:** The candidate is deployed to `staging`; its release criteria still need -verification. `production` has not been changed. +### Release Confidence {#sample-pursuit-release-confidence} -**Next:** -- `ask` Decide which observable checks define release readiness for the `staging` candidate. +Understand what makes a release safe and useful to the people using the application. -**Done when:** Current evidence supports an explicit release or no-release decision -for moving the candidate to `production`. +### Simpler Onboarding {#sample-pursuit-onboarding} diff --git a/README.md b/README.md index a000ed2..f04c509 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # RightMemory -**Durable Memory, live Pursuit, and reusable Agent Corrections for AI coding agents.** +**Durable Memory, a human-owned Pursuit map, and reusable Agent Corrections for AI coding agents.** -RightMemory gives people and coding agents three semantic modules: durable context lives in Memory, live intent lives in Pursuit, and reusable cases of user redirection live in Agent Corrections. Memory and Pursuit are Markdown document trees in one addressable graph; Agent Corrections is a fixed, non-graph case library. Shared views provide controlled collaboration between RightMemory roots. The same context can move across sessions, devices, users, agent clients, and collaborating agent teams instead of living inside one vendor UI. +RightMemory gives people and coding agents three semantic modules: durable context lives in Memory, the user's directions form a Pursuit map, and reusable cases of user redirection live in Agent Corrections. Memory and Pursuit are Markdown document trees in one addressable graph; Agent Corrections is a fixed, non-graph case library. Shared views provide controlled collaboration between RightMemory roots. The same context can move across sessions, devices, users, agent clients, and collaborating agent teams instead of living inside one vendor UI. ![How one RightMemory root works](docs/assets/rightmemory-single-root.svg) @@ -25,7 +25,7 @@ RightMemory is not only a place to store and retrieve context. Its semantic mode | Module | What it preserves | What it is not | | --- | --- | --- | | **Memory** | Durable context that should remain useful beyond the current task or session. | A transcript, task log, or record of everything that happened. | -| **Pursuit** | Live or deliberately parked intent, its hierarchy, and the context needed to understand its current direction. | A backlog, work log, or detailed execution record. | +| **Pursuit** | Directions the user wants visible in a hierarchical map, with optional context needed to understand or enter them. | A backlog, task manager, work log, or record of completed work. | | **Agent Corrections** | Bounded, reusable cases in which the user redirected prior agent work. | A collection of every correction or a substitute for general behavior guidance. | Memory and Pursuit form one addressable graph organized as two Markdown document trees. Agent Corrections remains a bounded, non-graph case library. @@ -33,11 +33,11 @@ Memory and Pursuit form one addressable graph organized as two Markdown document ### What Belongs Where? - Use **Memory** when durable context can materially improve future action, judgment, interpretation, or retrieval. -- Use **Pursuit** when the intent itself should remain active or deliberately parked after the current update. +- Add to **Pursuit** when the user chooses to represent a direction in the map. - Use **Agent Corrections** when a concrete user redirection forms a reusable case whose contrast would lose value if generalized away. - Store nothing when the evidence is transient, duplicated, insufficiently settled, or better preserved in project artifacts. -The updater may change any combination of the three modules—or decide that the submitted evidence should not be stored at all. +Update reconciles Memory and Agent Correction evidence, or stores nothing. Pursuit changes come from the human editor or the explicitly requested `maintain-pursuit-map` workflow; ordinary agent activity does not change the map. Canonical definitions and rules: [Memory Rules](rightmemory/reference/MEMORY_RULES.md), [Pursuit Rules](rightmemory/reference/PURSUIT_RULES.md), [Agent Corrections Rules](rightmemory/reference/AGENT_CORRECTION_MEMORY_RULES.md), and the [RightMemory Schema](rightmemory/reference/rightmemory-schema.md). @@ -82,7 +82,7 @@ cd RightMemory .\install.ps1 ``` -The default install uses standalone mode, creates `~/.rightmemory`, installs the `rightmemory` CLI, and installs the automatic `rightmemory-auto-orchestrator` skill into both `~/.codex/skills` and `~/.claude/skills`. On Windows, `~` means your PowerShell home directory. Any agent that can run shell commands, including Gemini CLI-style workflows, can call the CLI directly; the packaged skill install currently targets Codex and Claude Code. +The default install uses standalone mode, creates `~/.rightmemory`, installs the `rightmemory` CLI, and installs `rightmemory-auto-orchestrator` alongside the independent `maintain-pursuit-map`, `maintain-rightmemory`, and `review-agent-guidance-inbox` skills into both `~/.codex/skills` and `~/.claude/skills`. The three maintenance skills require explicit user requests. On Windows, `~` means your PowerShell home directory. Any agent that can run shell commands, including Gemini CLI-style workflows, can call the CLI directly; the packaged skill install currently targets Codex and Claude Code. If you want RightMemory roles to run through the Codex SDK or Claude Code CLI: @@ -103,7 +103,9 @@ After install, add a short instruction to your agent guidance file, such as Read the RightMemory MCP tool instructions, then automatically use the RightMemory MCP tools according to those instructions. ``` -The repository retains optional source skills for read-only retrieval, approval-gated orchestration, direct maintenance, and pending-guidance review, but the installer does not put them into an agent's active skill directory by default. The shared schema and focused rule documents define valid state, while the installed automatic skill defines how the host agent accesses it. +Read-only retrieval and approval-gated orchestration remain optional source skills. Choose one ordinary orchestration variant for a conversation; the independent maintenance skills are available alongside that choice. The shared schema and focused rule documents define valid state, while client skills define access and maintenance workflows. + +Open the visual map with `rightmemory pursuit`. It starts or reuses the existing Web Studio; sign in and select the intended root before editing. See [Pursuit Map](docs/PURSUIT_MAP.md) for the editor and its safety boundaries. Then start the background manager. It reviews idle agent sessions, evaluates prune generations, runs Dreamer consolidation, and produces Insight reflections when enough work has accumulated: @@ -141,7 +143,8 @@ When automatic orchestration encounters settled, potentially reusable agent guid The guidance inbox is synchronized but excluded from ordinary retrieval until the user explicitly reviews and promotes selected entries. Later: - the unified updater may change any combination of the three modules, or none + the unified updater may change Memory, Agent Corrections, both, or neither + the user's Pursuit map remains unchanged each queued outcome keeps its exact candidate batch beside the corresponding Git change rightmemory dreamer consolidates stale, duplicated, or overgrown memory rightmemory insight writes durable reflection artifacts when useful @@ -151,13 +154,13 @@ For a short recording script, see [docs/DEMO.md](docs/DEMO.md). ## What It Gives You -- Three semantic modules: durable Memory, live Pursuit, and reusable Agent Corrections. +- Three semantic modules: durable Memory, the user's Pursuit map, and reusable Agent Corrections. - One global id namespace and typed graph edges such as `dep:`, `cfg:`, `ver:`, `doc:`, and `todo:` across both trees. - Multi-device memory continuity across laptops, desktops, agent clients, and project-specific roots. -- Automatic orchestration installed by default, with other explicit workflows retained as optional source skills. +- Automatic orchestration plus independent explicit skills for map maintenance, Memory maintenance, and guidance review. - Two executor modes behind the same `rightmemory` CLI: standalone runtime or delegated Codex SDK/Claude Code CLI role execution. - Model-selected, runtime-rendered retrieval output without model-authored summaries or commentary. -- One updater for all three semantic modules, automatic transcript-review candidate extraction, and immutable candidate records for input-to-edit provenance. +- One updater for Memory and Agent Corrections, transcript-review candidate extraction, and immutable candidate records for input-to-edit provenance. ## Install Options And Updates @@ -193,7 +196,7 @@ candidate identities during package installation. The memory root must be a standalone, non-bare Git working tree. An existing target nested inside another working tree, a bare repository, or an unusable `.git` entry is refused before installation writes anything. -Reinstall replaces the superseded managed Memory-only orchestrator with the current four-skill surface and removes exact managed copies of former loose skills-root references. Modified loose files are left untouched. It does not keep an alias or a second behavior path. +Reinstall refreshes the automatic orchestration skill and the three independent maintenance skills. It removes exact managed copies of the other ordinary orchestration variants and former loose skills-root references; customized copies are left untouched. Independent skills are not part of the mutually exclusive orchestration choice. ### Profiles @@ -240,7 +243,7 @@ RightMemory is not trying to replace notes, search, or embeddings. It focuses on Memory and Pursuit are represented as ordinary Markdown document trees in one graph: - `MEMORY.md` and `MEMORY_.md` hold durable knowledge, context, preferences, decisions, constraints, and reusable guidance. -- `PURSUITS.md` and `PURSUIT_.md` hold live intent, Focus, current state, and next movements that should still shape future action. +- `PURSUITS.md` and reachable `PURSUIT_.md` files hold the user's direction map and ordered Focus references. Agent Corrections uses two fixed, non-graph files for concrete Expression and Substance cases. @@ -265,7 +268,7 @@ Runtime facts that apply to the whole project. The tree tells agents where to read in local context. Anchors and node ids tell agents what can be referenced. Edges tell agents where to walk across otherwise separate branches or between durable context and live intent. -A Pursuit is not a backlog or work log. It preserves an objective only while that intent remains part of the active or deliberately parked pursuit structure. Unfinished work alone does not qualify, and project-local artifacts remain the primary home for operational resume detail. Completed, abandoned, or superseded intent is removed, while only independently durable consequences move to Memory. +A Pursuit item is an addressable heading with a title, position in the tree, and optional free-form Markdown body. The body can explain meaning or stable entry context; it has no required task fields. The user decides when to create, rename, move, focus, or remove directions. When a direction is completed, abandoned, or superseded, remove it through an authorized map edit after considering independently durable consequences for Memory. Git preserves earlier map states. Common top-level domains include project or work domains, `# User Context`, and `# Cross-Session Agent Behavior`. User context stores the user's durable context profile. Agent behavior stores guidance about how coding agents should collaborate with that user. @@ -287,7 +290,7 @@ Nodes are addressable statements under a heading: - `` → [edge1, edge2, ...] ``` -Heading ids and node ids share one namespace across both trees. A node with no edges still writes `→ []`; a heading with no edges may omit the edge list. Pursuit `Next` control entries and `Focus` references are not graph nodes. +Heading ids and node ids share one namespace across both trees. A node with no edges still writes `→ []`; a heading with no edges may omit the edge list. New Pursuit map items use headings; `Focus` entries are references, not graph nodes. ### Detail Files @@ -295,6 +298,8 @@ Any `#`, `##`, or `###` heading can use its id as a parsed detail-file target by Move child content into a detail file when a heading becomes too dense, especially past about 15 direct node lines. Count only direct node lines, not child headings or `####` pointers. After moving content out, keep the F# heading and optional summary in the parent file. +The Pursuit editor creates `F#` boundaries automatically when a logical branch would exceed the physical heading limit. The canvas still shows one tree. Existing boundaries are retained rather than repeatedly splitting and inlining unchanged branches. + Memory also supports linked resources that are not parsed as graph content: `{M#slug}` points to free-form `MEMORY_.md` evidence, while `{S#slug}` points to reusable `MEMORY_SKILL_.md` instructions. M#, S#, MF#, and MQ# are Memory-only. File globs never determine graph membership. ### Shared Views @@ -413,7 +418,7 @@ Root `corrections.md` is synchronized RightMemory Edit Feedback, not semantic Ri ## Agent Roles -RightMemory separates ordinary work from state ownership. The default installer activates automatic orchestration. The repository also keeps optional source skills for explicitly selected workflows: +RightMemory separates ordinary work from state ownership. Automatic orchestration and three independent maintenance skills are installed by default; read-only retrieval and approval-gated orchestration remain optional alternatives for ordinary work: ```text memory-retriever @@ -430,34 +435,38 @@ rightmemory-auto-orchestrator maintain-rightmemory +--> MEMORY*.md direct coherent maintenance - +--> PURSUIT*.md direct coherent maintenance +--> correction surfaces direct rule-governed curation +maintain-pursuit-map / human map editor + +--> PURSUIT*.md user-requested direction map edits + +--> Memory edge repair mechanical reference cleanup on deletion + transcript review +--> candidate extraction idle supported sessions +--> unified update queue no direct graph edit unified updater +--> MEMORY*.md durable context - +--> PURSUIT*.md live intent and continuity +--> Agent Correction files reusable redirection cases +--> update_records/*.json exact candidate batch provenance ``` - `memory-retriever` retrieves relevant context and never submits updates. -- `rightmemory-orchestrator` is approval-gated. Once qualifying evidence is clear and the conversation reaches a natural boundary, it names the apparent module and reason, then submits only after approval. Stale, wrong, misleading, or overbroad retrieved state is submitted immediately so the agent does not continue from known-bad context. +- `rightmemory-orchestrator` is approval-gated. Once qualifying evidence is clear and the conversation reaches a natural boundary, it names the apparent module and reason, then submits only after approval. Stale, wrong, misleading, or overbroad retrieved Memory and Agent Corrections are submitted immediately. Pursuit remains read-only even when stale. - `rightmemory-auto-orchestrator` applies the same admission bar but submits qualifying evidence automatically at a natural boundary. Completion is not required, and the beginning or end of work alone does not trigger either mode. -- `maintain-rightmemory` is explicit-only. The current agent edits Memory, Pursuit, linked content, and either correction surface directly under the schema and focused rules; it does not call Update, submit candidates, or invoke another RightMemory model role. -- If an optional orchestration skill is installed manually, use the selected approval-gated or automatic skill for that conversation rather than invoking both. RightMemory does not persist the choice. +- `maintain-rightmemory` handles explicitly approved maintenance of Memory, linked content, and correction surfaces. It reads Pursuit for context and preserves its references without editing the map. +- `maintain-pursuit-map` handles explicit map requests. A precise edit is already authorized; broad cleanup or uncertain restructuring needs a concise proposed tree change and approval. Neither maintenance skill calls the other, submits map work to Update, or starts a model role. +- `review-agent-guidance-inbox` separately proposes and applies user-approved admission of pending guidance. It does not edit Pursuit. +- Use one of `memory-retriever`, `rightmemory-orchestrator`, and `rightmemory-auto-orchestrator` for ordinary access in a conversation. This choice does not exclude independent maintenance skills, and RightMemory does not persist the choice. - Once selected, `memory-retriever` calls Retrieve for the stated memory need. The two orchestrators retrieve conditionally when stored context could materially affect how the agent understands or approaches the work and skip clearly self-contained requests. Every ordinary Retrieve call may select relevant Agent Corrections through `AC#writing` and `AC#design`. - `memory-retriever` discloses active or deferred preferences, workflow or behavior guidance, reusable instructions, and Agent Corrections in a fixed `[RightMemory] Retrieved guidance` block; ordinary facts, knowledge, and descriptive context are not disclosed this way. -- Evidence clears the orchestration bar only when omitting it would likely cause poorer future decisions or substantial rediscovery, lose meaningful Pursuit context, or allow a settled reusable failure pattern to recur. Transient progress, routine results, incompleteness by itself, and detail already preserved in project artifacts do not qualify. +- Evidence clears the Update submission bar when omitting it would likely cause poorer future decisions or substantial rediscovery, or allow a settled reusable failure pattern to recur. Transient progress, routine results, incompleteness by itself, and detail already preserved in project artifacts do not qualify. Ordinary work does not trigger a map-change submission or suggestion. - Candidate submission is evidence, not final stored wording, classification, placement, or an instruction to edit a particular module. Session ids provide provenance and batching boundaries, not task identity. -- The unified updater reconciles related candidates and may change Memory, Pursuit, Agent Corrections, any combination of them, or nothing in one isolated transaction. +- The unified updater reconciles related candidates and may change Memory, Agent Corrections, both, or neither in one isolated transaction. If evidence also requests a map change, Update judges the durable portion and reports the Pursuit portion as skipped. Tool permissions and isolated-result validation reject Update writes to Pursuit files. - Dreamer, Insight, Historian, and Pruner remain Memory-oriented maintenance roles. They must preserve ids and edges referenced from Pursuit. Sync repair transports the wider synchronized surface without taking over semantic updater judgment. - Standalone mode uses RightMemory's bounded tools, while CLI-agent mode delegates roles to the Codex SDK or Claude Code CLI with role-specific permission defaults. Codex read roles run read-only. Codex write roles run with full filesystem access and no approval prompts so they can complete Git operations in their isolated task worktrees. -The host agent should avoid reading or editing RightMemory state directly unless the user explicitly installs and selects `maintain-rightmemory`. Other access goes through the command-backed skill and runtime roles, which keeps ownership clear and reduces partial or competing edits. +Ordinary host-agent access goes through the command-backed skills or MCP tools. Direct semantic edits belong to an explicitly requested maintenance workflow within its scope. Pursuit rules remain canonical in [PURSUIT_RULES.md](rightmemory/reference/PURSUIT_RULES.md). ## Prompt Sources @@ -484,8 +493,8 @@ RightMemory has two install modes. The default is `standalone`. | Mode | Use When | What Gets Installed | | --- | --- | --- | -| `standalone` | You want RightMemory to run its own local Pydantic AI role agents and tools. | Automatic `rightmemory-auto-orchestrator`, its shared definitions, and the `rightmemory` CLI. | -| `cli-agent` | You want RightMemory to delegate each runtime role turn to the Codex SDK or Claude Code CLI. | The same automatic skill and shared definitions plus the `rightmemory` CLI. | +| `standalone` | You want RightMemory to run its own local Pydantic AI role agents and tools. | The CLI, package definitions, automatic orchestration, and the three independent maintenance skills. | +| `cli-agent` | You want RightMemory to delegate each runtime role turn to the Codex SDK or Claude Code CLI. | The same CLI, definitions, and skills, with delegated role execution. | The installer arguments are: @@ -499,7 +508,7 @@ The installer arguments are: - `` is where Memory, Pursuit, optional Agent Correction collections, optional RightMemory Edit Feedback, sharing state, and `insight_logs/` live. - `` is where your agent loads skills from, such as `~/.claude/skills` or `~/.codex/skills`. -- With no path arguments, the installer uses `~/.rightmemory` and installs `rightmemory-auto-orchestrator` into `~/.codex/skills` and `~/.claude/skills`. +- With no path arguments, the installer uses `~/.rightmemory` and installs `rightmemory-auto-orchestrator`, `maintain-rightmemory`, `review-agent-guidance-inbox`, and `maintain-pursuit-map` into `~/.codex/skills` and `~/.claude/skills`. Both modes require `git` and `uv`. On macOS, Linux, and WSL, the runtime is installed under `${XDG_DATA_HOME:-$HOME/.local/share}/rightmemory/venv`, and the @@ -531,9 +540,9 @@ semantic work and push successful memory changes after they land. ## Everyday Use -1. Let the installed `rightmemory-auto-orchestrator` retrieve and submit automatically according to its instructions. Install one of the optional source skills manually only when you intentionally want its narrower workflow. +1. Let the installed `rightmemory-auto-orchestrator` retrieve context and submit qualifying Memory evidence according to its instructions. Use an optional ordinary orchestration variant when you want read-only or approval-gated access. 2. Run `rightmemory watch start` for transcript extraction, pruning, consolidation, Insight cycles, and optional sync. -3. Let the automatic orchestrator submit qualifying evidence itself at a natural boundary. +3. Open `rightmemory pursuit` or explicitly request `maintain-pursuit-map` to change your direction map. Ordinary updates leave it unchanged. 4. Use `rightmemory status` when you need to inspect watcher, queue, and sync state. Dreamer consolidates durable Memory when it needs structural cleanup. Insight commits timestamped reflections under `insight_logs/` when broader patterns, risks, or next-step ideas are worth preserving. @@ -949,7 +958,7 @@ update_candidate_points = 1.0 check_interval_seconds = 3000 ``` -A landed Memory-changing transaction adds one configured unit to Dreamer and Insight regardless of how many candidates the updater reconciled. Pursuit-only, no-op, and failed updates add no pressure. Transcript review only submits evidence to the unified queue and adds no pressure by itself. A successful automatic cycle consumes that role's configured threshold after it lands or completes as a valid no-op; failed cycles preserve accumulated points. `rightmemory dreamer watch --interval ` and `rightmemory insight watch --interval ` override the trigger-check cadence. +A landed Memory-changing Update transaction adds one configured unit to Dreamer and Insight regardless of how many candidates the updater reconciled. No-op and failed updates add no pressure. Transcript review only submits evidence to the unified queue and adds no pressure by itself. A successful automatic cycle consumes that role's configured threshold after it lands or completes as a valid no-op; failed cycles preserve accumulated points. `rightmemory dreamer watch --interval ` and `rightmemory insight watch --interval ` override the trigger-check cadence. Transcript-review scans share their lock with the corresponding watcher under `.runtime/watch/`; dreamer, insight, and pruner watchers hold their own locks there as well. A competing scan or watcher exits instead of duplicating work. Unified Update also uses one updater execution lock. Isolated roles may do model work in temporary checkouts, and landing uses the shared write lock before changing the main repository. @@ -966,7 +975,7 @@ when Windows assigns it a new PID. Run `rightmemory watch start` or ### Isolated Automatic Writes -Automatic unified-Update, dreamer, insight, and pruner turns that operate on the main state root run in temporary Git worktrees under `/.runtime/worktrees/` on branches named `rightmemory-isolated--`. Update may commit any meaningful combination of Memory, Pursuit, and Agent Correction files, and runtime adds an immutable candidate record to the same commit for queued work. A queued no-change outcome still commits its candidate record; an explicit Update turn without queued candidates adds no managed artifact. Memory-oriented maintenance roles remain restricted to Memory, while Insight commits `insight_logs/*.md`. Runtime validates complete role-owned results and lands successful commits; empty `prune:` checkpoints are allowed. +Automatic unified-Update, dreamer, insight, and pruner turns that operate on the main state root run in temporary Git worktrees under `/.runtime/worktrees/` on branches named `rightmemory-isolated--`. Update may commit Memory and Agent Correction files, and runtime adds an immutable candidate record to the same commit for queued work. Pursuit paths are excluded from Update's writable tools and accepted result surface. A queued no-change outcome still commits its candidate record; an explicit Update turn without queued candidates adds no managed artifact. Memory-oriented maintenance roles remain restricted to Memory, while Insight commits `insight_logs/*.md`. Runtime validates complete role-owned results and lands successful commits; empty `prune:` checkpoints are allowed. Temporary session and provider state lives under `.runtime/isolated-state/` during an isolated turn and is promoted after successful landing or a valid no-op. Standalone turns seed local message history there. CLI-agent turns start speculative provider work in a fresh one-shot session. Successful ownership state is promoted; if later validation or landing fails, the ownership record alone is preserved so the abandoned internal thread remains excluded from transcript review and eligible for cleanup. Other temporary work is discarded, and the original candidate batch or maintenance trigger balance remains the retry source. @@ -1024,8 +1033,10 @@ Repository: ```text RightMemory/ ├── README.md +├── DESIGN_NOTES.md ├── docs/ │ ├── DEMO.md +│ ├── PURSUIT_MAP.md │ └── assets/ ├── install.sh ├── install.ps1 @@ -1045,10 +1056,12 @@ RightMemory/ │ ├── SHARED_VIEW_RULES.md │ └── RETRIEVE_CONTRACT.md └── skills/ + ├── maintain-pursuit-map/SKILL.md ├── maintain-rightmemory/SKILL.md ├── memory-retriever-cli/SKILL.md ├── rightmemory-orchestrator-cli/SKILL.md ├── rightmemory-auto-orchestrator-cli/SKILL.md + ├── review-agent-guidance-inbox/SKILL.md ├── review-rightmemory-session/SKILL.md └── provider-transcript-normalizer/SKILL.md ``` @@ -1073,7 +1086,10 @@ After install: └── .runtime/ # machine-local runtime state ~/.codex/skills/ -└── rightmemory-auto-orchestrator/SKILL.md +├── rightmemory-auto-orchestrator/SKILL.md +├── maintain-rightmemory/SKILL.md +├── review-agent-guidance-inbox/SKILL.md +└── maintain-pursuit-map/SKILL.md ``` On native Windows, the default memory root is `~\.rightmemory`, and the CLI shim @@ -1082,13 +1098,15 @@ is `%LOCALAPPDATA%\RightMemory\bin\rightmemory.cmd`. ## Design Notes - Memory and Pursuit are separate document trees in one globally addressable graph; Agent Corrections is a third, non-graph semantic module. -- Human readability is useful, but agent retrieval is the primary design center. +- Memory supports agent retrieval; Pursuit also has a human-facing visual editor over the same canonical Markdown. - `MEMORY.md` and `PURSUITS.md` remain useful documents, not routing-only indexes. -- Approval-gated and automatic orchestration share one evidence bar and submit at natural boundaries; the unified updater owns final admission, wording, placement, and lifecycle transitions. -- Automatic state edits remain owned by dedicated RightMemory roles; direct host-agent edits require explicit selection of `maintain-rightmemory`. +- Approval-gated and automatic orchestration submit durable evidence at natural boundaries; the updater owns Memory and Agent Correction admission, wording, and placement. The user owns Pursuit semantics. +- Direct host-agent edits require an explicit maintenance request, using the workflow responsible for that content. - Dreamer consolidation and Insight reflection are explicit because structural cleanup and reflective artifacts have different authority boundaries. - `corrections.md` stays RightMemory Edit Feedback rather than becoming Memory, Pursuit, Agent Corrections, or graph content. +See [DESIGN_NOTES.md](DESIGN_NOTES.md) for the Pursuit map's ownership and storage rationale. + ## License Copyright 2026 RightL. diff --git a/docs/DEMO.md b/docs/DEMO.md index c6da2c7..640ed63 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -27,25 +27,21 @@ Choose the approval-gated `rightmemory-orchestrator` for this recording so the e - `sync-conflict-policy` Sync repair preserves non-identical updater-correction entries without ranking them. → [rel:sync-reconciler] ``` -Replace `/tmp/rightmemory-demo/PURSUITS.md` with the live continuation: +Create this small direction map in the disposable demo root: ```md # Pursuits ## Focus -- `sync-continuity` — finish the sync lifecycle demonstration. +- `sync-continuity` -## Finish Sync Continuity {#sync-continuity} → [dep:sync-design] +## Sync Continuity {#sync-continuity} → [dep:sync-design] -Make the demo show how durable sync context guides work that still needs continuation. +Help the same context remain useful across devices and agent sessions. +Start from the repository's sync documentation. -**State:** The durable sync policy is recorded; the demo flow still needs verification. - -**Next:** -- `do` Verify the retrieval and update sequence in a fresh agent session. - -**Done when:** The recording shows retrieval, a qualifying proposal and approval at a natural boundary, submission, and one coherent updater result. +### Clear Conflict Recovery {#sync-conflict-recovery} ``` Add one small Substance Correction so ordinary Retrieve can demonstrate the third module without a separate collection command: @@ -63,15 +59,15 @@ When describing orchestration, the agent turned contextual evidence judgment int 1. Open a fresh Codex or Claude Code session in the RightMemory repo. 2. Ask: `Continue the sync design from last time without requiring me to paste prior context.` 3. Show the user-selected `rightmemory-orchestrator` calling `rightmemory retrieve`. -4. Show a small retrieved result containing the `Sync Design` Memory context, the linked `Finish Sync Continuity` Pursuit, and the complete Substance Correction selected through `AC#design`. +4. Show a small retrieved result containing the `Sync Design` Memory context, the linked `Sync Continuity` Pursuit, and the complete Substance Correction selected through `AC#design`. 5. Ask the agent to make a tiny doc edit or explain the next implementation step. -6. Let a durable decision or meaningful Pursuit change become clear. At a natural boundary, show the agent proposing the apparent module and why the evidence qualifies, then obtain approval and call `rightmemory update submit`. A routine start or completion should not generate its own proposal. -7. Explain that the unified updater may change any meaningful combination of Memory, Pursuit, and Agent Corrections, or none, after reconciling the evidence. +6. Let an independently durable decision become clear. At a natural boundary, show the agent proposing why the evidence qualifies, then obtain approval and call `rightmemory update submit`. A routine start or completion should not generate its own proposal. +7. Explain that Update may change Memory or Agent Corrections after reconciling evidence; the direction map stays unchanged. Open `rightmemory pursuit` to show that a human can edit the map directly. 8. End on `git diff`, the relevant semantic files, or `rightmemory status` so viewers see ordinary Git-managed state rather than opaque vendor storage. ## Narration -> RightMemory has three semantic modules: durable Memory, live Pursuit, and reusable Agent Corrections. Memory and Pursuit share one addressable graph, while ordinary retrieval can also select relevant Expression or Substance correction cases through their fixed writing and design identifiers. At a natural boundary, the selected orchestrator proposes qualifying evidence for approval or submits it automatically. One updater owns the final wording, classification, and placement, and Git sync keeps the state portable. +> RightMemory has three semantic modules: durable Memory, the user's Pursuit map, and reusable Agent Corrections. Memory and Pursuit share one addressable graph, while retrieval can also select relevant correction cases. At a natural boundary, the selected orchestrator proposes qualifying durable evidence for approval or submits it automatically. Update maintains Memory and Agent Corrections. The user owns the map, and Git sync keeps the state portable. ## README GIF Placeholder diff --git a/docs/MCP.md b/docs/MCP.md index deddac8..6de9e40 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -39,12 +39,12 @@ For a named profile, use `"args": ["--profile", "my-project", "mcp"]`. The server exposes exactly three tools: - `rightmemory_retrieve` retrieves cross-session context when it could materially affect the current work. -- `rightmemory_submit_update` submits durable Memory or Pursuit evidence to the asynchronous unified Update queue. +- `rightmemory_submit_update` submits durable Memory and explicitly requested agent-guidance evidence to the asynchronous Update queue. Pursuit is read-only context, not an Update destination. - `rightmemory_capture_guidance` captures plausible reusable agent-behavior evidence, including explicit and implicit user redirections. The tool and parameter descriptions contain the complete automatic ordinary-agent contract. An MCP client should not also load the RightMemory orchestrator skill; that skill remains the CLI transport for clients without MCP support. -Successful writes return no model-visible content. A write result contains text only when the agent must act, such as when evidence was saved but the Update worker could not start, or when queued work requires manual recovery. Update submission never reports synchronous semantic acceptance: the updater reconciles submitted evidence later and may change any relevant module or none. +Successful writes return no model-visible content. A write result contains text only when the agent must act, such as when evidence was saved but the Update worker could not start, or when queued work requires manual recovery. Update submission never reports synchronous semantic acceptance: the updater reconciles submitted evidence later and may change Memory, Agent Corrections, both, or neither. Guidance capture favors recall. A signal need not already be a fully settled general rule, and independent later occurrences of a similar pattern may be captured again. Capture does not replace applying the user's direction to the current work. @@ -52,4 +52,6 @@ Guidance capture favors recall. A signal need not already be a fully settled gen The MCP adapter calls the same runtime, async Update store, and guidance capture implementation as the CLI. The CLI remains available for human inspection, explicit maintenance, queue status, retry, undo, and clients that cannot use MCP. +Ordinary MCP work does not create, rename, reorganize, focus, or remove Pursuits. Explicit map requests use the human editor or the independent `maintain-pursuit-map` skill; no additional MCP map-write tools are provided. + This command currently serves stdio only. RightMemory does not expose a Streamable HTTP MCP endpoint. diff --git a/docs/PURSUIT_MAP.md b/docs/PURSUIT_MAP.md new file mode 100644 index 0000000..6ee37af --- /dev/null +++ b/docs/PURSUIT_MAP.md @@ -0,0 +1,122 @@ +# Pursuit Map + +The Pursuit map is a visual editor for directions you want to keep visible. A node normally has a title and, when useful, a Markdown note. Its place in the tree explains how it relates to broader directions. It has no task status, required next action, or execution link. + +## Open The Map + +```bash +rightmemory pursuit +``` + +This starts or reuses the existing Web Studio and opens its Pursuit Map view. Use `rightmemory pursuit --no-open` to print the address without opening a browser, or `rightmemory --profile pursuit` for a named profile. The command does not create a separate service or a second authentication system. + +Sign in with the Web Studio operator token and check the active root before editing. A newly generated token is printed by the launch command; an existing service keeps its existing token and sessions. The map uses that authenticated session's selected root. Starting the service or opening the map does not rewrite Pursuit files. + +## Edit On The Canvas + +Branches extend from left to right. A single top-level direction is the visual root; several top-level directions appear under a virtual “Pursuits” root that is not stored as a new map item. + +| Action | Interaction | +| --- | --- | +| Select a node | Click its title. | +| Rename | Double-click the title or press `F2`; edit in place. | +| Create a sibling | Press `Enter` and type the title. | +| Create a child | Press `Tab` and type the title. | +| Promote a branch | Press `Shift+Tab`. | +| Move or reorder a branch | Drag it to the indicated insertion position. | +| Delete a branch | Press `Delete` or `Backspace`; undo is available after deletion. | +| Fold a branch | Use its small branch control or press `Space`. | +| Navigate the tree | Use arrow keys; `Home` and `End` jump through visible nodes. | +| Search titles and notes | Press `Ctrl/Cmd+F`; `Enter` and `Shift+Enter` move through results. | +| Fit the map | Use Fit or `Ctrl/Cmd+0`. | +| Undo or redo | Use `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, or `Ctrl/Cmd+Y` for redo. | +| Edit a note | Open its note control, press `N`, or press `Shift+Enter`. | +| Mark current attention | Toggle its Focus marker or press `F`. | + +New nodes receive stable ids automatically. Renaming or moving a node preserves its id. Physical files, heading depth, and graph syntax stay out of the normal editing controls. + +Toolbar controls provide the same editing actions. **More** includes top-level creation and the read-only relation summary. Right-drag empty canvas space or use the wheel/trackpad to pan; use the zoom buttons or `Ctrl/Cmd` with the wheel to zoom. + +The note editor holds raw Markdown, not a set of generated task fields. Save with its button or `Ctrl/Cmd+S`. Closing the panel also saves; a failed save leaves it open with the text intact. Keep stable context near the direction it explains, or in a shared ancestor when it applies to a whole branch. Detailed progress, commands, test output, and experiment history belong in project artifacts. + +Focus is an ordered attention marker. It does not grant permission to execute work or turn the map into a queue. A direction can remain visible without being focused. + +## Saving, Conflicts, And Undo + +The canvas shows an edit immediately and sends confirmed changes in order. Typing a title or dragging a pointer does not create a Git commit for every intermediate step. A confirmed semantic interaction produces a validated Git change; the returned snapshot is authoritative. + +Every write carries the revision it was based on. If another editor, sync, or an agent changes the root, a stale operation is rejected and the current snapshot is returned. The interface reports the conflict rather than silently overwriting the newer state. Failed note and title edits remain available for review and retry in the current page. These drafts are temporary, not stored in a separate database; preserve any needed text before closing or reloading the page. + +Undo and redo create new commits. They never reset the shared branch or erase another writer's history. The service accepts only history associated with the current editing session and rejects operations whose expected state no longer matches. The browser's undo stack lasts for the current page and is cleared after an external revision change or conflict; Git still preserves the commits. + +The active root must be clean and valid for a map transaction. Unrelated dirty files are not absorbed into map commits. Candidate edits run in a temporary worktree, validate against the complete Memory/Pursuit graph, and land only while the active root still matches the captured state. A rejected operation leaves active files unchanged. + +The editor also refuses roots whose Git settings could conceal or rewrite map data: `assume-unchanged` or `skip-worktree` index flags, ignored untracked canonical files, and checkout or filter settings that would alter existing or intended file bytes. The refusal explains the condition; the editor does not change Git configuration or normalize those files automatically. + +Deleting a node removes its entire logical subtree, including unreachable Pursuit detail files. The transaction also removes deleted ids from Focus and typed edges that target them in either Memory or Pursuit. It leaves prose mentions untouched and reports repaired references. This mechanical reference repair does not authorize rewriting Memory content. + +## Canonical Storage + +The map remains ordinary RightMemory Markdown: + +```md +# Pursuits + +## Focus + +- `example-application` + +## Example Application {#example-application} + +Start from the repository's current design document. + +### Simpler Onboarding {#example-onboarding} +``` + +`PURSUITS.md` and reachable `PURSUIT_.md` files are the source of truth. The editor reads the canonical graph index instead of maintaining a separate database. Deep logical branches continue through `F#` detail files so each physical file can obey the shared heading rules. The renderer creates required boundaries automatically and preserves existing boundaries where possible. + +Pan, zoom, folding, and selection are browser-local view state. They are not stored in Markdown and do not affect retrieval. + +Existing old field blocks remain readable as body text until the user explicitly requests cleanup. Their former `do`, `ask`, and `wait` labels do not control agent actions. Existing plain structural headings and graph-node bullets are preserved; content that cannot be safely edited through the normal map controls is surfaced as a read-only item or diagnostic. Opening the map is not a migration. + +When a direction is completed, abandoned, or superseded, remove it through an explicit map edit after considering any independently durable consequence for Memory. Earlier map states remain in Git. A natural branch such as “Later” can express something you still want visible; there is no archived or parked status system. + +## Agent Ownership + +The human editor and an explicit `maintain-pursuit-map` request are the two normal semantic write entrances. A precise request such as “Rename this map branch to Research” authorizes that edit. Broad requests such as “Simplify my map” first produce a concise proposed tree change for approval. + +Update, ordinary orchestration, transcript review, and Memory maintenance may read Pursuit. They do not infer, submit, or apply map changes from progress, unfinished work, new ideas, or completion. Update can retain independently durable evidence from a mixed candidate while reporting its Pursuit portion as skipped. Sync repair can reconcile already-authorized map state within its existing bounded repair workflow. + +Both install modes include `maintain-pursuit-map` alongside `maintain-rightmemory` and `review-agent-guidance-inbox`. These are independent skills, not alternatives to ordinary orchestration. They do not invoke one another or exchange queued map work. + +## Implementation Entry Points + +| Area | Source | +| --- | --- | +| Meaning and ownership | [Pursuit rules](../rightmemory/reference/PURSUIT_RULES.md) | +| Graph grammar and index | [graph.py](../rightmemory/graph.py), [shared schema](../rightmemory/reference/rightmemory-schema.md) | +| Logical tree and Markdown rendering | [pursuit_tree.py](../rightmemory/pursuit_tree.py) | +| Transactions, revisions, and history | [pursuit_store.py](../rightmemory/pursuit_store.py) | +| Existing Web service and map routes | [web/app.py](../rightmemory/web/app.py), [web/service.py](../rightmemory/web/service.py) | +| Browser source and static build | [web/frontend](../rightmemory/web/frontend/), [web/static](../rightmemory/web/static/) | +| Direct agent editing | [maintain-pursuit-map](../skills/maintain-pursuit-map/SKILL.md) | + +The internal API is `GET /api/pursuit-map`, `POST /api/pursuit-map/operations`, and the corresponding `/undo` and `/redo` endpoints. Mutations use the existing authenticated session, active-root selection, and CSRF protection. A revision conflict returns HTTP `409` with a fresh snapshot. These routes serve the editor; there is no public create/edit/move command family or task-execution API. + +For frontend changes, install locked dependencies and run the focused checks from `rightmemory/web/frontend`: + +```bash +npm ci +npm run typecheck +npm test +npm run build +``` + +The production build is shipped with the Python package, so end users do not need Node or runtime CDN access. For executable changes, also run the repository checks: + +```bash +python -m tests +python -m compileall -q rightmemory tests +``` + +Use disposable roots for browser and destructive transaction checks. Agent-facing rules, prompts, and skill wording are reviewed directly rather than asserted by tests. `.xmind` interchange, typed-edge editing controls, task registries, task execution, and task reconciliation are outside this map's scope. diff --git a/docs/RIGHTMEMORY_MAINTAINER_BACKGROUND.md b/docs/RIGHTMEMORY_MAINTAINER_BACKGROUND.md index 2a02669..8e5cacc 100644 --- a/docs/RIGHTMEMORY_MAINTAINER_BACKGROUND.md +++ b/docs/RIGHTMEMORY_MAINTAINER_BACKGROUND.md @@ -9,7 +9,7 @@ RightMemory is curated cross-session context. It should preserve compact state t Its three semantic modules are: - **Memory**: durable context that improves future judgment, action, interpretation, or retrieval. -- **Pursuit**: meaningful pursuits and goals, their hierarchy, and their current direction. +- **Pursuit**: the user's hierarchical map of ongoing directions and useful entry context. - **Agent Corrections**: bounded, reusable cases in which user feedback redirected prior agent work. Project-local artifacts remain the primary home for implementation detail, experiment history, operational state, and detailed continuation instructions. Pursuit may support continuity, but it is not primarily a resume log. @@ -32,6 +32,7 @@ The governing bias for semantic RightMemory is **high-signal retention rather th | `update-role.md` | Internal Update agent | Candidate reconciliation and edit procedure | | `rightmemory-orchestrator.SKILL.md`, `rightmemory-auto-orchestrator.SKILL.md` | Ordinary main agent | Approval-gated or automatic use of RightMemory | | `review-agent-guidance-inbox.SKILL.md` | Explicitly invoked general agent | User-reviewed admission or rejection of pending agent guidance | +| `maintain-pursuit-map/SKILL.md` | Explicitly invoked general agent | Requested map edits, separate from Update and ordinary maintenance | | `rightmemory/guidance.py`, sync and tool boundaries | Runtime | Inbox format, atomic capture, deterministic merge, validation, and model-role isolation | The orchestrator files are **client skills read by the main working agent**, not internal orchestrator roles. Update is an internal RightMemory agent. The guidance-review skill is a separate direct-curation workflow used through agents such as Codex or Claude Code. @@ -55,14 +56,14 @@ Other files may rely on a definition without restating it. A compact operational ### Pursuit -Pursuit is a hierarchical system of meaningful pursuits, goals, and directions. It records what should become true, why it matters, where it sits in the hierarchy, and enough context to understand its current direction. Detailed execution continuity belongs mainly in project artifacts. Incompleteness alone does not justify a Pursuit. +Pursuit is a human-owned map of ongoing directions. An item has a title, stable id, position in the tree, and optional Markdown body. Detailed execution continuity belongs in project artifacts. Incompleteness alone does not justify a Pursuit. The human editor and explicitly invoked `maintain-pursuit-map` are the normal semantic write entrances; Update and ordinary maintenance read the map without changing it. See [Pursuit Map](PURSUIT_MAP.md) for use and [design rationale](../DESIGN_NOTES.md) for the ownership boundary. ### Orchestration Modes Both orchestrator skills share the same retrieval policy and candidate discipline, but guidance capture now differs from formal semantic admission. -- The approval-gated skill proposes qualifying Memory, Pursuit, or Agent Correction evidence and submits it to Update only after user approval. An explicit request to submit, save, remember, or follow the evidence in future counts as approval. -- The automatic skill sends qualifying Memory and Pursuit evidence directly to Update at a natural boundary. +- The approval-gated skill proposes qualifying Memory or Agent Correction evidence and submits it to Update only after user approval. An explicit request to submit, save, remember, or follow the evidence in future counts as approval. +- The automatic skill sends qualifying Memory evidence directly to Update at a natural boundary. Neither mode submits or proposes Pursuit changes from ordinary task activity. - In automatic mode, settled agent guidance or user redirection goes to `AGENT_GUIDANCE_INBOX.md` when the resulting direction is clear and may be useful in similar future work. Unresolved discussion and obviously one-off local adjustments are excluded. - When the user explicitly asks RightMemory to remember guidance or follow it in future, automatic mode sends it through Update rather than the inbox. - One interaction may produce both an Update candidate and a guidance-inbox entry when they preserve distinct evidence. @@ -99,7 +100,7 @@ Automatic Reviewer is deprecated for now. Do not modify or depend on it for the RightMemory does not require a visible retrieved-guidance block or a fixed phrase such as `Correction noted.` Applicable guidance should affect the work itself; those mechanisms were compensations for weak agent behavior, not product semantics. -Update may repair a clear schema or supplied-rule violation that becomes apparent while processing an update. It should not inspect unrelated content merely to turn the work into a general audit, and should report rather than guess when the correct repair is uncertain. +Update may repair a clear schema or supplied-rule violation in its writable Memory or Agent Correction state when that violation becomes apparent while processing an update. Pursuit stays read-only even when a map repair seems useful. Update should not inspect unrelated content merely to turn the work into a general audit, and should report rather than guess when the correct repair is uncertain. ## How to Modify RightMemory diff --git a/docs/assets/rightmemory-single-root.svg b/docs/assets/rightmemory-single-root.svg index 9c24ba2..25bf8f5 100644 --- a/docs/assets/rightmemory-single-root.svg +++ b/docs/assets/rightmemory-single-root.svg @@ -1,19 +1,19 @@ How one RightMemory root works - A diagram showing an agent retrieving context from and submitting evidence to one RightMemory root where durable Memory and live Pursuit form one graph. + An agent reads context from a shared graph. Update maintains durable Memory while the user owns the Pursuit map. One RightMemory root - An agent retrieves relevant context, submits evidence, and lets one updater reconcile both trees. + An agent reads the shared graph. Update maintains Memory; the user owns the Pursuit map. Agent session asks for relevant context - After work - submits state candidates + Durable evidence + submits Update candidates RightMemory root @@ -26,9 +26,9 @@ Pursuit - live intent - state and next - movement + user-owned + directions and + entry context one graph · global ids + typed edges diff --git a/pyproject.toml b/pyproject.toml index f58fa5a..ac70741 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ rightmemory = "rightmemory.entrypoint:main" [tool.hatch.build.targets.wheel] packages = ["rightmemory"] +exclude = ["rightmemory/web/frontend/**"] [tool.hatch.build.targets.wheel.force-include] "MEMORY.example.md" = "rightmemory/MEMORY.example.md" diff --git a/rightmemory/cli.py b/rightmemory/cli.py index 3f5b87d..bb891b0 100644 --- a/rightmemory/cli.py +++ b/rightmemory/cli.py @@ -10,6 +10,7 @@ import subprocess import sys import time +import webbrowser from base64 import urlsafe_b64encode from contextlib import contextmanager from dataclasses import replace @@ -189,6 +190,8 @@ def main(argv: list[str] | None = None) -> int: return _agent_cli_main(argv[1:], memory_root) if argv and argv[0] == "web": return _web_main(argv[1:], memory_root) + if argv and argv[0] == "pursuit": + return _pursuit_main(argv[1:], memory_root) if argv and argv[0] == "review": return _review_main(argv[1:], memory_root) if argv and argv[0] == "sync": @@ -307,7 +310,10 @@ def _parse_global_args(argv: list[str]) -> tuple[str | None, list[str]]: def _top_level_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="rightmemory", description="RightMemory memory runtime") + parser = argparse.ArgumentParser( + prog="rightmemory", description="RightMemory memory runtime", + epilog="Open the visual Pursuit Map with: rightmemory pursuit [--no-open]", + ) parser.add_argument("--profile", help="named memory profile for runtime commands") parser.add_argument("role", nargs="?", choices=tuple(sorted(ROLES)), help="RightMemory runtime role") return parser @@ -901,6 +907,41 @@ def _is_http_url(value: str) -> bool: return value.startswith("http://") or value.startswith("https://") +def _pursuit_main(argv: list[str], memory_root: Path) -> int: + parser = argparse.ArgumentParser(prog="rightmemory pursuit", description="Open the Pursuit Map in Web Studio") + parser.add_argument("--no-open", action="store_true", help="print the map URL without opening a browser") + args = parser.parse_args(argv) + try: + existing = web_service_status(memory_root) + service = start_web_service( + memory_root, host=existing.host or "127.0.0.1", port=existing.port if existing.port is not None else 8766, + ) + except (OSError, RuntimeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(format_web_status(service)) + if not service.port: + print("error: Web Studio has no fixed port; use rightmemory web restart --port 8766.", file=sys.stderr) + return 1 + host = service.host or "127.0.0.1" + if host == "0.0.0.0": + host = "127.0.0.1" + elif host == "::": + host = "::1" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + url = f"http://{host}:{service.port}/#pursuit-map" + print(f"pursuit map: {url}") + if not args.no_open: + try: + opened = webbrowser.open(url) + except (webbrowser.Error, OSError): + opened = False + if not opened: + print("Could not open a browser; open the Pursuit Map URL above.", file=sys.stderr) + return 0 + + def _web_main(argv: list[str], memory_root: Path) -> int: parser = argparse.ArgumentParser(prog="rightmemory web") subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/rightmemory/graph.py b/rightmemory/graph.py index 25b3b23..c2978c7 100644 --- a/rightmemory/graph.py +++ b/rightmemory/graph.py @@ -37,8 +37,8 @@ UNSUPPORTED_ANCHOR_RE = re.compile(r"^(#{1,})\s+.*?\{([A-Za-z]+#)([^}]*)\}") NODE_RE = re.compile(r"^\s*-\s+`([^`]+)`.*?(?:\s(?:\u2192|->)\s*\[(.*?)\])\s*$") NODE_CANDIDATE_RE = re.compile(r"^\s*-\s+`([^`]+)`(?:\s|$)") -PURSUIT_ACTION_RE = re.compile(r"^\s*-\s+`([^`]+)`(?:\s|$)") -PURSUIT_FIELD_RE = re.compile(r"^\s*\*\*(State|Next|Done when|Status):\*\*", re.IGNORECASE) +# Reading compatibility for existing user data, not the current Pursuit schema. +LEGACY_PURSUIT_FIELD_RE = re.compile(r"^\s*\*\*(State|Next|Done when|Status):\*\*", re.IGNORECASE) EDGE_RE = re.compile(rf"^\s*([A-Za-z][A-Za-z0-9_-]*):\s*({ITEM_ID_PATTERN})\s*$") FOCUS_HEADING_RE = re.compile(r"^##\s+Focus\s*$", re.IGNORECASE) FOCUS_REFERENCE_RE = re.compile(rf"^\s*-\s+`({ITEM_ID_PATTERN})`(?:\s|$)") @@ -71,6 +71,118 @@ def validate_item_id(value: str) -> str: return value +@dataclass(frozen=True, slots=True) +class AddressableHeading: + depth: int + title: str + anchor_kind: str + id: str + edges: tuple[tuple[str, str], ...] + malformed_edges: tuple[str, ...] = () + + +def parse_addressable_heading(line: str) -> AddressableHeading | None: + """Read a heading using the same grammar as the canonical document index.""" + match = ANCHOR_RE.match(line.rstrip("\r\n")) + if match is None: + return None + heading = HEADING_RE.match(line) + assert heading is not None + edges, malformed = _parse_edges(match.group(4) or "") + return AddressableHeading( + depth=len(match.group(1)), + title=line[heading.end():match.start(2) - 1].strip(), + anchor_kind=match.group(2), + id=match.group(3), + edges=tuple(edges), + malformed_edges=tuple(malformed), + ) + + +def heading_title(line: str) -> str: + """Return the title of an addressed or plain heading, without its edges.""" + addressed = parse_addressable_heading(line) + if addressed is not None: + return addressed.title + heading = HEADING_RE.match(line) + if heading is None: + raise ValueError("expected a Markdown heading") + return line[heading.end():].strip() + + +def validate_heading_title(title: str) -> str: + """Reject titles that would change the structural meaning of a heading.""" + if not isinstance(title, str) or not title.strip(): + raise ValueError("title must be a nonempty string") + title = title.strip() + if len(title.splitlines()) != 1 or "\x00" in title: + raise ValueError("title must fit on one line") + probe = f"## {title}" + if ANCHOR_CANDIDATE_RE.match(probe) or UNSUPPORTED_ANCHOR_RE.match(probe): + raise ValueError("title must not contain a RightMemory heading anchor") + return title + + +def render_heading_line( + title: str, + anchor_kind: str, + item_id: str, + edges: Iterable[tuple[str, str]] = (), + *, + depth: int = 2, +) -> str: + """Render a new addressable heading; callers choose the document newline.""" + title = validate_heading_title(title) + validate_item_id(item_id) + if anchor_kind not in {"#", *TERMINAL_HEADING_KINDS}: + raise ValueError("unsupported heading anchor kind") + if not 1 <= depth <= 4 or (depth == 4 and anchor_kind not in TERMINAL_HEADING_KINDS): + raise ValueError("ordinary headings must have depth one through three") + edge_list = tuple(edges) + for edge_type, target in edge_list: + if edge_type not in KNOWN_EDGE_TYPES: + raise ValueError(f"unknown edge type: {edge_type}") + validate_item_id(target) + result = f"{'#' * depth} {title} {{{anchor_kind}{item_id}}}" + if edge_list: + result += " \u2192 [" + ", ".join(f"{kind}:{target}" for kind, target in edge_list) + "]" + return result + + +def replace_heading_title(line: str, title: str) -> str: + """Patch only an addressed heading's title, retaining its anchor and suffix.""" + title = validate_heading_title(title) + anchor = ANCHOR_RE.match(line.rstrip("\r\n")) + heading = HEADING_RE.match(line) + if anchor is None or heading is None: + raise ValueError("expected an addressable heading") + title_end = anchor.start(2) - 1 + old_title = line[heading.end():title_end] + spacing = old_title[len(old_title.rstrip()):] + return line[:heading.end()] + title + spacing + line[title_end:] + + +def remove_edge_targets(line: str, deleted_ids: set[str] | frozenset[str]) -> str: + """Remove selected typed edges without changing prose, other edges, or newlines.""" + source = line.rstrip("\r\n") + match = ANCHOR_RE.match(source) + group = 4 + if match is None: + match = NODE_RE.match(source) + group = 2 + if match is None or match.group(group) is None: + return line + tokens = match.group(group).split(",") + kept = [] + for token in tokens: + edge = EDGE_RE.match(token) + if edge is None or edge.group(2) not in deleted_ids: + kept.append(token) + if len(kept) == len(tokens): + return line + return line[:match.start(group)] + ",".join(kept) + line[match.end(group):] + + @dataclass(frozen=True) class SourceSpan: path: Path @@ -200,6 +312,18 @@ def walk_logical(self, key: BlockKey, *, include_self: bool = False) -> Iterable yield from self.walk_logical(part) +def span_text(manifest: GraphManifest, span: SourceSpan | None) -> str: + """Return an indexed source span with its exact original line endings.""" + if span is None: + return "" + document = manifest.documents[span.path] + return "".join(document.text.splitlines(keepends=True)[span.start_line - 1:span.end_line]) + + +def block_body_text(manifest: GraphManifest, block: DocumentBlock | GraphItem) -> str: + return span_text(manifest, block.body_span) + + @dataclass(frozen=True) class _ParseProfile: name: str @@ -543,7 +667,7 @@ def _parse_document( parent_key = stack[-1] if stack else document.root_key parent = manifest.blocks[parent_key] if document.family == "pursuit": - field_match = PURSUIT_FIELD_RE.match(line) + field_match = LEGACY_PURSUIT_FIELD_RE.match(line) if field_match: in_pursuit_next = field_match.group(1).casefold() == "next" _append_text(parent, line) @@ -581,18 +705,10 @@ def _parse_document( continue if document.family == "pursuit" and in_pursuit_next: - action = PURSUIT_ACTION_RE.match(line) - if action is not None: - if action.group(1) not in {"do", "ask", "wait"}: - _add_error( - manifest, - f"invalid Pursuit Next action `{action.group(1)}` at " - f"{document.relative_path}:{line_number}; use `do`, `ask`, or `wait`", - document.path, - line_number, - ) - _append_text(parent, line) - continue + # Older roots used Next lists. Retain every line as unstructured body + # until another legacy field or heading, including unknown actions. + _append_text(parent, line) + continue node_candidate = NODE_CANDIDATE_RE.match(line) if node_candidate is not None: diff --git a/rightmemory/install_core.py b/rightmemory/install_core.py index 263274b..cbf68d3 100644 --- a/rightmemory/install_core.py +++ b/rightmemory/install_core.py @@ -636,19 +636,23 @@ def _install_skills(self) -> None: for source_directory, skill_name in ( ("memory-retriever-cli", "memory-retriever"), ("rightmemory-orchestrator-cli", "rightmemory-orchestrator"), + ): + self._remove_managed_skill( + self.repo_root / "skills" / source_directory / "SKILL.md", + skill_name, + target, + ) + for source_directory, skill_name in ( + ("rightmemory-auto-orchestrator-cli", "rightmemory-auto-orchestrator"), ("maintain-rightmemory", "maintain-rightmemory"), ("review-agent-guidance-inbox", "review-agent-guidance-inbox"), + ("maintain-pursuit-map", "maintain-pursuit-map"), ): - self._remove_managed_skill( + self._install_skill( self.repo_root / "skills" / source_directory / "SKILL.md", skill_name, target, ) - self._install_skill( - self.repo_root / "skills" / "rightmemory-auto-orchestrator-cli" / "SKILL.md", - "rightmemory-auto-orchestrator", - target, - ) self._remove_old_skill("memory-orchestrator", target) self._remove_old_skill("memory-curator", target) self._remove_old_skill("memory-dreamer", target) @@ -815,7 +819,12 @@ def _print_next_steps(self) -> None: "qualifying evidence automatically according to its instructions." ) print( - " 4. Optional background transcript review, dreamer, pruning, insight, and sync: " + " 4. Open the Pursuit map with rightmemory pursuit, or explicitly use " + "maintain-pursuit-map. Memory maintenance and pending-guidance review " + "are installed as separate explicit skills." + ) + print( + " 5. Optional background transcript review, dreamer, pruning, insight, and sync: " "rightmemory watch start" ) print() diff --git a/rightmemory/isolated_write.py b/rightmemory/isolated_write.py index e212aaa..e587533 100644 --- a/rightmemory/isolated_write.py +++ b/rightmemory/isolated_write.py @@ -6,6 +6,7 @@ import re import shutil import subprocess +import tempfile import uuid from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass @@ -20,7 +21,13 @@ SemanticOperationRecord, SemanticOperationStore, ) -from .graph import MEMORY_DETAIL_FILE_RE, PURSUIT_DETAIL_FILE_RE +from .graph import ( + MEMORY_DETAIL_FILE_RE, + PURSUIT_DETAIL_FILE_RE, + GraphManifest, + build_graph_manifest, + remove_edge_targets, +) from .tools import ( CORRECTIONS_PATH, FIXED_CORRECTION_COLLECTION_PATHS, @@ -107,10 +114,29 @@ class IsolatedWriteResult: prepared: bool = False +@dataclass(frozen=True) +class _CommitGraphSnapshot: + graph: GraphManifest + files: dict[str, bytes] + modes: dict[str, str] + + class IsolatedWriteSupervisor: - def __init__(self, memory_root: Path, role: str): + def __init__( + self, + memory_root: Path, + role: str, + *, + pursuit_restore_commit: str | None = None, + ): self.memory_root = Path(memory_root).resolve() self.role = role + if pursuit_restore_commit is not None: + if role != "pursuit-map": + raise ValueError("Pursuit reference restoration requires the pursuit-map role") + if re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", pursuit_restore_commit) is None: + raise ValueError("Pursuit reference restoration requires a full commit hash") + self.pursuit_restore_commit = pursuit_restore_commit def recover_prepared(self) -> None: """Finish durable outcomes before a caller starts another model turn.""" @@ -318,6 +344,9 @@ def _run_claimed( commits = self._temp_commits(worktree, start_head) if commits: if operation_id is not None: + # Squashing must not conceal a forbidden intermediate write. + for commit in commits: + self._validate_commits(worktree, [commit]) commits = [self._collapse_operation_commits(worktree, start_head, commits, operation_id)] self._validate_candidate(worktree, commits) @@ -460,6 +489,13 @@ def _resume_prepared_operation( dirty = self._dirty_memory_files() if dirty: raise MainMemoryDirtyError(dirty) + # Prepared outcomes can outlive a role-policy change. + # Recheck the current fence before publishing their saved tree. + managed_paths = frozenset( + path for path in self._commit_paths(self.memory_root, candidate) + if self.role == "update" and _is_managed_update_artifact_path(path) + ) + self._validate_commits(self.memory_root, [candidate], managed_paths=managed_paths) if current_head == outcome.start_commit: self._land_operation_commit(candidate) else: @@ -627,7 +663,7 @@ def _validate_commits( *, managed_paths: frozenset[str] = frozenset(), ) -> None: - if self.role == "update" and len(commits) > 1: + if self.role in {"update", "pursuit-map"} and len(commits) > 1: raise RuntimeError(f"one {self.role} turn must land at most one commit") for commit in commits: changed_paths = self._commit_paths(worktree, commit) @@ -643,6 +679,8 @@ def _validate_commits( label = "non-insight paths" if self.role == "insight" else "non-memory paths" raise RuntimeError(f"isolated commit touches {label}: {paths}") self._validate_commit_tree(worktree, commit, set(changed_paths)) + if self.role == "pursuit-map": + self._validate_pursuit_memory_changes(worktree, commit, set(changed_paths)) def _validate_candidate( self, @@ -805,10 +843,11 @@ def _is_role_write_path(self, path: str) -> bool: return bool(INSIGHT_LOG_FILE_RE.fullmatch(path)) if self.role == "reviewer": return False + if self.role == "pursuit-map": + # Memory paths also require byte-exact mechanical repair proof below. + return self._is_graph_rightmemory_path(path) if path in FIXED_CORRECTION_COLLECTION_PATHS: return self.role in {"update", "sync-reconciler"} - if self.role == "update": - return self._is_graph_rightmemory_path(path) if self.role == "sync-reconciler": return ( self._is_graph_rightmemory_path(path) @@ -829,6 +868,172 @@ def _is_graph_rightmemory_path(self, path: str) -> bool: or bool(PURSUIT_DETAIL_FILE_RE.fullmatch(path)) ) + def _validate_pursuit_memory_changes( + self, worktree: Path, commit: str, changed_paths: set[str] + ) -> None: + memory_paths = { + path for path in changed_paths + if path == "MEMORY.md" or MEMORY_DETAIL_FILE_RE.fullmatch(path) + } + if not memory_paths: + return + parent = self._single_commit_parent(worktree, commit) + before = self._commit_graph_snapshot(worktree, parent) + after = self._commit_graph_snapshot(worktree, commit) + if self._is_pursuit_edge_removal(before, after, memory_paths): + return + if self.pursuit_restore_commit is not None: + source = self.pursuit_restore_commit + if not self._is_ancestor(source, parent): + raise RuntimeError("Pursuit reference restoration source is not in the current history") + source_paths = set(self._commit_paths(worktree, source)) + if any(not self._is_role_write_path(path) for path in source_paths): + raise RuntimeError("Pursuit reference restoration source changes unrelated files") + source_memory_paths = { + path for path in source_paths + if path == "MEMORY.md" or MEMORY_DETAIL_FILE_RE.fullmatch(path) + } + source_before = self._commit_graph_snapshot( + worktree, self._single_commit_parent(worktree, source) + ) + source_after = self._commit_graph_snapshot(worktree, source) + if ( + source_memory_paths + and self._is_pursuit_edge_removal(source_before, source_after, source_memory_paths) + and memory_paths == source_memory_paths + and all( + before.files.get(path) == source_after.files.get(path) + and after.files.get(path) == source_before.files.get(path) + and before.modes.get(path) == after.modes.get(path) + for path in memory_paths + ) + and self._removed_pursuit_ids(source_before.graph, source_after.graph) + <= self._removed_pursuit_ids(after.graph, before.graph) + ): + return + paths = ", ".join(sorted(memory_paths)) + raise RuntimeError( + "pursuit-map may change Memory only to repair references to deleted Pursuit ids " + f"or restore a verified deletion: {paths}" + ) + + def _is_pursuit_edge_removal( + self, + before: _CommitGraphSnapshot, + after: _CommitGraphSnapshot, + changed_memory_paths: set[str], + ) -> bool: + before_graph = before.graph + after_graph = after.graph + if before_graph.errors or after_graph.errors: + return False + deleted_ids = self._removed_pursuit_ids(before_graph, after_graph) + expected: dict[str, list[str]] = {} + for item in before_graph.items.values(): + if item.family != "memory" or not any(target in deleted_ids for _, target in item.edges): + continue + relative_path = item.file.relative_to(before_graph.root).as_posix() + if relative_path not in expected: + expected[relative_path] = before.files[relative_path].decode("utf-8").splitlines(keepends=True) + lines = expected[relative_path] + lines[item.line_number - 1] = remove_edge_targets(lines[item.line_number - 1], deleted_ids) + return set(expected) == changed_memory_paths and all( + "".join(lines).encode("utf-8") == after.files.get(path) + and before.modes.get(path) == after.modes.get(path) + for path, lines in expected.items() + ) + + @staticmethod + def _removed_pursuit_ids(before: GraphManifest, after: GraphManifest) -> set[str]: + return { + item.id for item in before.items.values() + if item.family == "pursuit" and item.id not in after.items + } + + def _single_commit_parent(self, worktree: Path, commit: str) -> str: + parents = self._git_stdout(worktree, "rev-list", "--parents", "-n", "1", commit).split() + if len(parents) != 2: + raise RuntimeError("Pursuit map edits require a commit with one parent") + return parents[1] + + def _commit_graph_snapshot( + self, worktree: Path, commit: str + ) -> _CommitGraphSnapshot: + """Parse immutable Git blobs with the canonical graph index, never a second grammar.""" + entries = self._git_stdout(worktree, "ls-tree", "-r", "-z", commit) + files: dict[str, bytes] = {} + modes: dict[str, str] = {} + objects: dict[str, str] = {} + for entry in entries.split("\0"): + if not entry: + continue + metadata, _, path = entry.partition("\t") + if not ( + path in {"MEMORY.md", "PURSUITS.md"} + or MEMORY_DETAIL_FILE_RE.fullmatch(path) + or PURSUIT_DETAIL_FILE_RE.fullmatch(path) + ): + continue + mode, kind, object_id = metadata.split() + if kind != "blob" or mode not in {"100644", "100755"}: + raise RuntimeError(f"memory path is not a regular file: {path}") + objects[path] = object_id + modes[path] = mode + blobs = self._read_git_blobs(worktree, objects.values()) + runtime = self.memory_root / ".runtime" + runtime.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="pursuit-fence-", dir=runtime) as temporary: + snapshot = Path(temporary) + for path, object_id in objects.items(): + content = blobs[object_id] + files[path] = content + (snapshot / path).write_bytes(content) + graph = build_graph_manifest(snapshot) + return _CommitGraphSnapshot(graph=graph, files=files, modes=modes) + + def _read_git_blobs(self, worktree: Path, object_ids: Iterable[str]) -> dict[str, bytes]: + # Text-mode subprocess output would normalize CRLF and invalidate byte proof. + requested = tuple(dict.fromkeys(object_ids)) + if not requested: + return {} + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + env["GIT_ASKPASS"] = "true" + try: + result = subprocess.run( + ["git", "cat-file", "--batch"], + cwd=worktree, + input="".join(f"{object_id}\n" for object_id in requested).encode("ascii"), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + timeout=GIT_TIMEOUT_SECONDS, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + raise RuntimeError("could not read immutable Pursuit graph snapshot") from exc + if result.returncode: + raise RuntimeError(result.stderr.decode("utf-8", errors="replace").strip()) + blobs: dict[str, bytes] = {} + offset = 0 + for object_id in requested: + header_end = result.stdout.find(b"\n", offset) + header = result.stdout[offset:header_end].split() if header_end >= 0 else [] + if len(header) != 3 or header[:2] != [object_id.encode("ascii"), b"blob"]: + raise RuntimeError("invalid immutable Pursuit graph blob response") + try: + size = int(header[2]) + except ValueError as exc: + raise RuntimeError("invalid immutable Pursuit graph blob size") from exc + content_start = header_end + 1 + content_end = content_start + size + if size < 0 or result.stdout[content_end:content_end + 1] != b"\n": + raise RuntimeError("incomplete immutable Pursuit graph blob response") + blobs[object_id] = result.stdout[content_start:content_end] + offset = content_end + 1 + if offset != len(result.stdout): + raise RuntimeError("unexpected immutable Pursuit graph blob response") + return blobs + def _validate_regular_memory_path(self, worktree: Path, commit: str, path: str, required: bool) -> None: tree_entry = self._tree_entry(worktree, commit, path) if tree_entry is None: diff --git a/rightmemory/mcp.py b/rightmemory/mcp.py index f378523..3532d61 100644 --- a/rightmemory/mcp.py +++ b/rightmemory/mcp.py @@ -54,9 +54,11 @@ the correction and current evidence with rightmemory_submit_update.""" SUBMIT_UPDATE_DESCRIPTION = """\ -Submit evidence for durable cross-session context or the current direction of meaningful -ongoing work when omitting it would likely cause poorer future decisions, substantial -rediscovery, or loss of continuity. +Submit durable Memory or Agent Correction evidence when omitting it would likely cause +poorer future decisions, substantial rediscovery, or loss of useful context. + +Pursuit is read-only to Update. Explicit map edits belong in the human editor or the +maintain-pursuit-map workflow, not in an Update submission. Submit at a natural boundary once the evidence is clear; completion is not required. Do not submit transient progress, routine results, unresolved discussion by itself, or diff --git a/rightmemory/prompt.py b/rightmemory/prompt.py index e2df16f..e76b993 100644 --- a/rightmemory/prompt.py +++ b/rightmemory/prompt.py @@ -172,8 +172,8 @@ def _command_guidance(role: str) -> str: "- The `rightmemory update` command selected unified updating. Treat every caller message as a " "RightMemory candidate without requiring a dispatch prefix.\n" "- A caller message may contain one candidate or an ordered batch. Reconcile the evidence into " - "Memory, Pursuit, Agent Corrections, any meaningful combination of them, or nowhere instead of " - "treating candidate text as final stored content." + "Memory, Agent Corrections, both, or nowhere instead of treating candidate text as final stored " + "content. Pursuit is read-only context; skip proposed map changes." ) if role == "pruner": return ( @@ -310,9 +310,8 @@ def _tool_guidance(role: str) -> str: ) elif role == "update": guidance += ( - "\n- Commit tools are scoped to `MEMORY.md`, `MEMORY_*.md`, `PURSUITS.md`, `PURSUIT_*.md`, " - "and the fixed Agent Corrections collections. `corrections.md` is read-only; keep unrelated files " - "out of commits." + "\n- Commit tools are scoped to `MEMORY.md`, `MEMORY_*.md`, and the fixed Agent Corrections " + "collections. Pursuit files and `corrections.md` are read-only; keep unrelated files out of commits." ) else: guidance += ( diff --git a/rightmemory/prompts/reviewer.md b/rightmemory/prompts/reviewer.md index e351abc..0c31a18 100644 --- a/rightmemory/prompts/reviewer.md +++ b/rightmemory/prompts/reviewer.md @@ -6,9 +6,9 @@ signals and returns them to the caller as one candidate bundle; the caller durably queues that bundle for the unified updater. Never edit, stage, or commit RightMemory files. Never submit an update yourself. -The updater, not the reviewer, decides whether a signal changes live Pursuit -state, durable Memory, Agent Corrections, more than one of those modules, or -none of them. +The updater, not the reviewer, decides whether a signal changes durable Memory, +Agent Corrections, both, or neither. Pursuit is read-only context; this review +does not extract proposed map changes from task activity. ## Review Input @@ -20,7 +20,7 @@ Review the batch as a whole. The sessions are usually historical and may not represent the latest project state. Extract only signals that may still help a later updater reconcile current RightMemory state: -- concrete live work, commitments, blockers, decisions, or follow-ups; +- decisions or constraints with independently durable consequences; - durable user context, preferences, stable setup facts, or reusable lessons; - user redirections of identifiable prior agent work, whether explicit or implicit; @@ -30,7 +30,7 @@ later updater reconcile current RightMemory state: Ordinary progress narration, generic summaries, resolved transient failures, speculation, and partial turns are not candidates unless they expose a reusable -lesson or an unresolved state change. +lesson or other independently durable context. Treat explicit user approval or rejection of a proposed RightMemory update as evidence. Do not infer rejection from silence, omission, or a change of topic. @@ -43,7 +43,7 @@ duplicates and to describe conflicts accurately. Do not treat a historical transcript as automatically newer or more authoritative than current state. Preserve meaning rather than copying dialogue. Do not assign graph ids, choose -headings, prescribe files, or classify a signal as Memory, Pursuit, or an Agent +headings, prescribe files, or classify a signal as Memory or an Agent Correction. Preserve provenance so the updater can evaluate the evidence: include the transcript source and session id for each extracted signal, and distinguish direct user statements from inferences. diff --git a/rightmemory/prompts/update.md b/rightmemory/prompts/update.md index aca94bc..078ddc4 100644 --- a/rightmemory/prompts/update.md +++ b/rightmemory/prompts/update.md @@ -1,15 +1,16 @@ # Update Role -Reconcile the supplied candidates into valid current RightMemory state. Candidates are evidence, not stored wording or destination instructions. Most candidate content should be filtered out. +Reconcile the supplied candidates into durable Memory and Agent Corrections. Candidates are evidence, not stored wording or destination instructions. Most candidate content should be filtered out. Pursuit is read-only context, not an Update destination. ## Reconcile - Read both graph roots before the first edit and inspect the existing items and backing resources relevant to the candidates. - Reconcile the batch as a whole. Session ids are provenance and batching labels, not task identity. -- Group candidates by the work or Pursuit they concern, using meaning and evidence rather than merely session ids or submission labels. A stable task label may help when present but is not required. -- Treat related start, progress, blockage, waiting, direction change, handoff, and completion evidence as an evolving account. Preserve the latest state supported by the complete thread rather than every event. +- Group candidates by the work they concern, using meaning and evidence rather than merely session ids or submission labels. A stable task label may help when present but is not required. +- Reconcile related evidence before judging its durable consequence. Progress, blockage, waiting, next steps, handoff, and completion do not form a Pursuit state thread for Update to maintain. - Compare each thread with current RightMemory. Update a matching canonical item instead of creating a duplicate, including when the caller did not know its id. -- Use the canonical module rules to decide whether evidence belongs in Memory, Pursuit, Agent Corrections, more than one module for distinct reasons, or nowhere. +- Use the canonical module rules to decide whether evidence belongs in Memory, Agent Corrections, both for distinct reasons, or nowhere. +- If a candidate mixes durable evidence with a possible Pursuit map change, judge the durable portion normally and report the map portion as skipped. Do not infer or apply Pursuit changes. - Do not preserve candidate ids, submission labels, or operational event names unless they independently matter to the user. - Revalidate claims that may have become stale instead of copying candidates as fact. @@ -24,7 +25,8 @@ Reconcile the supplied candidates into valid current RightMemory state. Candidat ## Repair And Safety -- Repair any clear schema or supplied package-rule violation that becomes apparent while processing the update, whether through reading or validation. Do not inspect unrelated content solely to find additional violations. If the correct repair is uncertain, leave it unchanged. +- Repair a clear schema or supplied package-rule violation in Update's writable Memory or Agent Correction state when it becomes apparent while processing the update. Do not inspect unrelated content solely to find additional violations. Leave uncertain repairs and all Pursuit changes untouched, and report them. +- Preserve Memory ids referenced from Pursuit. An Update repair does not authorize editing Pursuit files or removing their references. - Preserve unrelated valid content while using enough scope to keep the complete affected graph coherent. - Validate the complete graph before finishing. - If state changed, stage and commit only allowed RightMemory files touched by the update. diff --git a/rightmemory/pursuit_store.py b/rightmemory/pursuit_store.py new file mode 100644 index 0000000..f3f5d23 --- /dev/null +++ b/rightmemory/pursuit_store.py @@ -0,0 +1,608 @@ +"""Validated Git transactions for the human-owned Pursuit map. + +The browser owns its transient undo stack. Git owns the map and its history; +there is no saved editor draft or second map database here. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import re +import stat +import uuid +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .graph import ( + MEMORY_DETAIL_FILE_RE, + PURSUIT_DETAIL_FILE_RE, + GraphManifest, + build_graph_manifest, +) +from .isolated_write import IsolatedWriteSupervisor, MainMemoryDirtyError, OPERATION_TRAILER +from .pursuit_tree import PursuitEdit, PursuitOperationError, apply_operation, load_pursuit_tree +from .tools import CORRECTIONS_PATH, MemoryTools + + +EDITOR_ROLE = "pursuit-map" +_ACTIONS = frozenset({"create", "rename", "move", "delete", "edit_body", "set_focus"}) +_HISTORY_ACTIONS = frozenset({"undo", "redo"}) +_COMMIT_RE = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") +_DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z") +_TRAILERS = { + "editor": "RightMemory-Pursuit-Editor", + "operation": OPERATION_TRAILER, + "session": "RightMemory-Pursuit-Session", + "action": "RightMemory-Pursuit-Action", + "target": "RightMemory-Pursuit-Target", + "signature": "RightMemory-Pursuit-Signature", +} + + +class PursuitStoreError(RuntimeError): + """A stable error boundary for the existing Web API.""" + + def __init__( + self, + code: str, + message: str, + status: int = 400, + diagnostics: Iterable[str] = (), + ): + super().__init__(message) + self.code = code + self.status = status + self.diagnostics = tuple(diagnostics) + + +@dataclass(frozen=True) +class _RepositoryState: + head: str = "" + branch: str = "" + dirty_paths: tuple[str, ...] = () + diagnostics: tuple[str, ...] = () + + +class PursuitStore: + def __init__(self, root: Path): + self.root = Path(root).expanduser().resolve() + self.root_key = hashlib.sha256(os.path.normcase(str(self.root)).encode("utf-8")).hexdigest() + self._reader = IsolatedWriteSupervisor(self.root, EDITOR_ROLE) + + def snapshot(self) -> dict[str, Any]: + """Return a readable projection, including why a root cannot be edited. + + Reads do not create runtime state. The two revision reads prevent a + concurrent publication from producing an apparently editable mixed view. + """ + snapshot: dict[str, Any] = {} + for _attempt in range(2): + state = self._repository_state() + revision = self._revision(state.head, state.branch) + errors: list[str] = [] + try: + snapshot = load_pursuit_tree(self.root).to_dict() + except (OSError, ValueError) as exc: + snapshot = {"items": [], "root_ids": [], "focus_ids": [], "diagnostics": []} + errors.append(str(exc)) + try: + errors.extend(_validation_errors(self.root)) + except (OSError, ValueError) as exc: + errors.append(str(exc)) + current_head = self._head_or_empty() + current_revision = self._revision(current_head) + stable = revision == current_revision + diagnostics = list(dict.fromkeys([ + *snapshot.get("diagnostics", []), + *errors, + *state.diagnostics, + *(["Uncommitted changes: " + ", ".join(state.dirty_paths)] if state.dirty_paths else []), + ])) + snapshot.update( + revision=current_revision, + git_head=current_head, + root_key=self.root_key, + valid=not errors, + writable=bool(stable and not errors and state.head and not state.diagnostics and not state.dirty_paths), + dirty_paths=list(state.dirty_paths), + diagnostics=diagnostics, + ) + if stable: + return snapshot + snapshot["writable"] = False + snapshot["diagnostics"].append("The root changed while it was being read. Reload the map.") + return snapshot + + def apply( + self, + operation: Mapping[str, Any], + expected_revision: str, + session_id: str, + ) -> dict[str, Any]: + if ( + not isinstance(operation, Mapping) + or not isinstance(operation.get("type"), str) + or operation["type"] not in _ACTIONS + ): + raise PursuitStoreError("invalid_operation", "Unknown Pursuit map operation.", 422) + operation = dict(operation) + action = operation["type"] + + def edit(candidate: Path, supervisor: _PursuitSupervisor) -> PursuitEdit: + try: + return apply_operation(candidate, operation) + except PursuitOperationError as exc: + raise PursuitStoreError("invalid_operation", str(exc), 422) from exc + + return self._transact(edit, expected_revision, session_id, action=action) + + def undo(self, commit: str, expected_revision: str, session_id: str) -> dict[str, Any]: + return self._revert(commit, expected_revision, session_id, action="undo") + + def redo(self, commit: str, expected_revision: str, session_id: str) -> dict[str, Any]: + return self._revert(commit, expected_revision, session_id, action="redo") + + def _revert( + self, + commit: str, + expected_revision: str, + session_id: str, + *, + action: str, + ) -> dict[str, Any]: + if not isinstance(commit, str) or _COMMIT_RE.fullmatch(commit) is None: + raise PursuitStoreError("history_forbidden", "History requires an exact editor commit id.", 403) + + def edit(candidate: Path, supervisor: _PursuitSupervisor) -> PursuitEdit: + self._verify_history(commit, supervisor.expected_head, session_id, action) + before = build_graph_manifest(candidate) + result = supervisor._run_git(candidate, "revert", "--no-commit", commit, check=False) + if result.returncode: + raise PursuitStoreError( + "history_conflict", "This history change conflicts with the current map. Reload the map.", 409, + ) + after = build_graph_manifest(candidate) + if after.errors: + raise PursuitStoreError("history_conflict", "This history change would invalidate the graph.", 409, after.errors) + paths = _status_paths(supervisor, candidate) + return PursuitEdit( + changed_paths=tuple(paths), + repaired_references=tuple(_reference_changes(before, after)), + selected_id=None, + description=f"pursuit: {action} {commit[:12]}", + ) + + return self._transact( + edit, + expected_revision, + session_id, + action=action, + target=commit, + ) + + def _transact( + self, + edit: Callable[[Path, _PursuitSupervisor], PursuitEdit], + expected_revision: str, + session_id: str, + *, + action: str, + target: str = "-", + ) -> dict[str, Any]: + _validate_request_identity(expected_revision, session_id) + # Mutations need the base fence and validation, not a second projected + # tree. The shared supervisor repeats this fence before publication. + base = self._repository_state() + if self._revision(base.head, base.branch) != expected_revision: + raise PursuitStoreError("conflict", "The map changed. Reload it before retrying your edit.", 409) + if base.dirty_paths: + raise PursuitStoreError("dirty_root", "The root has uncommitted changes. Commit or discard them before editing.", 409, base.dirty_paths) + try: + errors = _validation_errors(self.root) + except (OSError, ValueError) as exc: + errors = [str(exc)] + if errors: + raise PursuitStoreError("invalid_root", "The root is invalid and can only be viewed.", 422, errors) + if not base.head or base.diagnostics: + raise PursuitStoreError("read_only", "The root is not ready for a Git transaction.", 409, base.diagnostics) + baseline_files = { + name: (self.root / name).read_bytes() + for name in self._semantic_names() if (self.root / name).is_file() + } + + operation_id = uuid.uuid4().hex + supervisor = _PursuitSupervisor( + self, + expected_revision, + base.head, + session_id, + # The role fence independently proves a historical deletion before + # allowing its exact inverse to restore any Memory edge bytes. + pursuit_restore_commit=target if action in _HISTORY_ACTIONS else None, + ) + + def prepare(candidate: Path) -> PursuitEdit: + supervisor.candidate_root = candidate + converted = [ + name for name, content in baseline_files.items() + if not (candidate / name).is_file() or (candidate / name).read_bytes() != content + ] + if converted: + raise PursuitStoreError( + "read_only", + "Git checkout would change existing file bytes. Review line-ending and filter settings before editing.", + 409, converted, + ) + result = edit(candidate, supervisor) + actual_paths = set(_status_paths(supervisor, candidate)) + declared_paths = set(result.changed_paths) + if actual_paths != declared_paths: + raise PursuitStoreError("write_failed", "The editor changed files outside its declared operation.", 500) + if not actual_paths: + return result + for path in declared_paths: + _require_relative_path(path) + intended_files = { + path: (candidate / path).read_bytes() + for path in declared_paths if (candidate / path).is_file() + } + supervisor._run_git(candidate, "add", "-A", "--", *sorted(declared_paths)) + if supervisor._run_git(candidate, "diff", "--cached", "--quiet", check=False).returncode == 0: + return result + parent = supervisor._git_stdout(candidate, "rev-parse", "HEAD") + tree = supervisor._git_stdout(candidate, "write-tree") + metadata = { + "editor": "1", + "operation": operation_id, + "session": _session_hash(session_id), + "action": action, + "target": target, + } + metadata["signature"] = self._signature(metadata, parent, tree, session_id) + subject = result.description.strip().splitlines()[0] if result.description.strip() else f"pursuit: {action}" + message = subject + "\n\n" + "\n".join( + f"{name}: {metadata[key]}" for key, name in _TRAILERS.items() + ) + # A hook could add changes after validation or during publication. + # A linked worktree's .git is a file, so this hook directory cannot + # contain executable hooks. Do not change the user's Git config. + supervisor._run_git( + candidate, "-c", f"core.hooksPath={candidate / '.git' / 'hooks'}", + "commit", "-m", message, + ) + if intended_files: + # Exercise the same index checkout conversion before touching + # active files. A valid blob alone does not prove that Git's + # CRLF or custom filters preserve the intended physical bytes. + supervisor._run_git(candidate, "checkout-index", "--force", "--", *sorted(intended_files)) + converted = [ + path for path, content in intended_files.items() + if (candidate / path).read_bytes() != content + ] + if converted: + raise PursuitStoreError( + "read_only", + "Git checkout would change the edit's file bytes. Review line-ending and filter settings before editing.", + 409, converted, + ) + return result + + try: + result = supervisor.run(prepare) + except PursuitStoreError: + raise + except MainMemoryDirtyError as exc: + raise PursuitStoreError("dirty_root", "The root changed during the edit. No editor changes were published.", 409, exc.paths) from exc + except (RuntimeError, OSError, ValueError) as exc: + message = str(exc) + code = "invalid_operation" if message.startswith("validation failed:") else "write_failed" + raise PursuitStoreError(code, message, 422 if code == "invalid_operation" else 500) from exc + output: PursuitEdit = result.output + return { + "snapshot": supervisor.landed_snapshot or self.snapshot(), + "commit": result.landed_commit if result.commits_landed else None, + "operation_id": operation_id, + "repaired_references": list(output.repaired_references), + "undoable": bool(result.commits_landed), + "selected_id": output.selected_id, + } + + def _verify_history(self, commit: str, head: str, session_id: str, action: str) -> None: + metadata = self._authenticate_commit(commit, session_id) + permitted = _ACTIONS | {"redo"} if action == "undo" else {"undo"} + if metadata["action"] not in permitted: + raise PursuitStoreError("history_forbidden", "This commit cannot be used for that history operation.", 403) + if not self._reader._is_ancestor(commit, head): + raise PursuitStoreError("history_forbidden", "This editor commit is not in the active history.", 403) + later = self._reader._git_stdout( + self.root, "log", "--first-parent", "--format=%H%x00%P%x00%T%x00%B%x00", + f"{commit}..{head}", + ).split("\0") + for index in range(0, len(later) - 1, 4): + try: + current = self._authenticate_record( + later[index].strip(), later[index + 1], later[index + 2], later[index + 3], session_id, + ) + except PursuitStoreError as exc: + raise PursuitStoreError("history_conflict", "History changed outside this editor session. Reload the map.", 409) from exc + if current["target"] == commit: + raise PursuitStoreError("history_conflict", "This history change has already been applied.", 409) + + def _authenticate_commit(self, commit: str, session_id: str) -> dict[str, str]: + result = self._reader._run_git( + self.root, "show", "--no-patch", "--format=%H%x00%P%x00%T%x00%B", commit, check=False, + ) + try: + resolved, parent, tree, message = result.stdout.split("\0", 3) + if result.returncode or resolved != commit: + raise ValueError("unresolved commit") + return self._authenticate_record(resolved, parent, tree, message, session_id) + except ValueError as exc: + raise PursuitStoreError("history_forbidden", "This is not an authenticated commit from this editor session.", 403) from exc + + def _authenticate_record( + self, commit: str, parent: str, tree: str, message: str, session_id: str, + ) -> dict[str, str]: + try: + if _COMMIT_RE.fullmatch(commit) is None or _COMMIT_RE.fullmatch(parent) is None: + raise ValueError("not a single-parent commit") + values: dict[str, str] = {} + trailer_names = {value: key for key, value in _TRAILERS.items()} + for line in message.strip().rsplit("\n\n", 1)[-1].splitlines(): + name, separator, value = line.partition(": ") + if separator and name in trailer_names: + key = trailer_names[name] + if key in values: + raise ValueError("duplicate trailer") + values[key] = value + if set(values) != set(_TRAILERS): + raise ValueError("missing editor trailers") + if values["editor"] != "1" or values["session"] != _session_hash(session_id): + raise ValueError("different editor session") + if values["action"] not in _ACTIONS | _HISTORY_ACTIONS: + raise ValueError("invalid editor operation") + if re.fullmatch(r"[0-9a-f]{32}", values["operation"]) is None: + raise ValueError("invalid operation identity") + if _DIGEST_RE.fullmatch(values["signature"]) is None: + raise ValueError("invalid signature encoding") + if (values["action"] in _HISTORY_ACTIONS and _COMMIT_RE.fullmatch(values["target"]) is None) or ( + values["action"] in _ACTIONS and values["target"] != "-" + ): + raise ValueError("invalid history target") + expected = self._signature(values, parent, tree, session_id) + if not hmac.compare_digest(values["signature"], expected): + raise ValueError("invalid editor signature") + return values + except (ValueError, KeyError) as exc: + raise PursuitStoreError("history_forbidden", "This is not an authenticated commit from this editor session.", 403) from exc + + def _signature(self, metadata: Mapping[str, str], parent: str, tree: str, session_id: str) -> str: + payload = { + **{key: value for key, value in metadata.items() if key != "signature"}, + "root": self.root_key, + "parent": parent, + "tree": tree, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hmac.new(session_id.encode("utf-8"), encoded, hashlib.sha256).hexdigest() + + def _head_or_empty(self) -> str: + result = self._reader._run_git(self.root, "rev-parse", "--verify", "HEAD", check=False) + return result.stdout.strip() if result.returncode == 0 else "" + + def _repository_state(self) -> _RepositoryState: + result = self._reader._run_git( + self.root, "rev-parse", "--show-toplevel", "--absolute-git-dir", "HEAD", check=False, + ) + fields = result.stdout.strip().splitlines() + if result.returncode or len(fields) != 3 or Path(fields[0]).resolve() != self.root: + return _RepositoryState(diagnostics=("The memory root must be a Git repository root with an initial commit.",)) + git_dir = Path(fields[1]) + head = fields[2] + branch = self._reader._run_git(self.root, "symbolic-ref", "--quiet", "HEAD", check=False) + diagnostics = [] + if branch.returncode: + diagnostics.append("The memory root has a detached HEAD. Check out its active branch before editing.") + if any((git_dir / name).exists() for name in ( + "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer", + )): + diagnostics.append("Finish the existing Git merge, rebase, or history operation before editing.") + try: + dirty = set(_status_paths(self._reader, self.root)) + indexed = self._reader._run_git(self.root, "ls-files", "-v", "-z").stdout + tracked: set[str] = set() + hidden: list[str] = [] + for entry in indexed.split("\0"): + if not entry: + continue + tracked.add(entry[2:]) + if entry[0].islower() or entry[0] == "S": + hidden.append(entry[2:]) + if hidden: + diagnostics.append("Git index flags hide file changes: " + ", ".join(sorted(hidden))) + # Git may silently overwrite an ignored, untracked backing file + # during checkout. Such files are real semantic data, not runtime + # artifacts, and must be admitted to Git before editor publication. + for name in self._semantic_names(): + if name not in tracked and (self.root / name).exists(): + dirty.add(name) + except RuntimeError as exc: + dirty = set() + diagnostics.append(str(exc)) + return _RepositoryState(head, branch.stdout.strip(), tuple(sorted(dirty)), tuple(diagnostics)) + + def _revision(self, head: str, branch: str | None = None) -> str: + digest = hashlib.sha256() + digest.update(self.root_key.encode("ascii") + b"\0") + digest.update(head.encode("ascii", errors="replace") + b"\0") + if branch is None: + result = self._reader._run_git(self.root, "symbolic-ref", "--quiet", "HEAD", check=False) + branch = result.stdout.strip() if result.returncode == 0 else "" + digest.update(branch.encode("utf-8") + b"\0") + for name in sorted(self._semantic_names()): + digest.update(name.encode("utf-8") + b"\0") + path = self.root / name + try: + file_stat = path.lstat() + if stat.S_ISLNK(file_stat.st_mode): + content = b"link\0" + os.readlink(path).encode("utf-8") + elif stat.S_ISREG(file_stat.st_mode): + content = b"file\0" + path.read_bytes() + else: + content = b"non-regular\0" + except FileNotFoundError: + content = b"missing\0" + except OSError as exc: + content = f"unreadable\0{exc.errno}".encode("ascii") + digest.update(hashlib.sha256(content).digest()) + return digest.hexdigest() + + def _semantic_names(self) -> set[str]: + names = {"MEMORY.md", "PURSUITS.md", CORRECTIONS_PATH} + try: + names.update(path.name for path in self.root.iterdir() if ( + MEMORY_DETAIL_FILE_RE.fullmatch(path.name) or PURSUIT_DETAIL_FILE_RE.fullmatch(path.name) + )) + except OSError: + pass # Required missing/unreadable roots still enter the revision. + return names + + +class _PursuitSupervisor(IsolatedWriteSupervisor): + """Keep the shared candidate lifecycle, but never rebase an editor action.""" + + def __init__( + self, + store: PursuitStore, + expected_revision: str, + expected_head: str, + session_id: str, + *, + pursuit_restore_commit: str | None = None, + ): + super().__init__(store.root, EDITOR_ROLE, pursuit_restore_commit=pursuit_restore_commit) + self.store = store + self.expected_revision = expected_revision + self.expected_head = expected_head + self.session_id = session_id + self.candidate_root: Path | None = None + self.landed_snapshot: dict[str, Any] | None = None + + def _dirty_memory_files(self) -> list[str]: + state = self.store._repository_state() + if state.dirty_paths: + return list(state.dirty_paths) + if state.diagnostics: + raise PursuitStoreError("read_only", "The Git root is not ready for this edit.", 409, state.diagnostics) + if state.head != self.expected_head or self.store._revision(state.head, state.branch) != self.expected_revision: + raise PursuitStoreError("conflict", "The root changed during the edit. No editor changes were published.", 409) + return [] + + def _validate_candidate(self, worktree: Path, commits: list[str], **kwargs: Any) -> None: + if len(commits) != 1: + raise PursuitStoreError("write_failed", "A map interaction must produce exactly one commit.", 500) + parent = self._git_stdout(worktree, "show", "--no-patch", "--format=%P", commits[0]) + if parent != self.expected_head: + raise PursuitStoreError("conflict", "The editor candidate no longer has its expected base.", 409) + super()._validate_candidate(worktree, commits, **kwargs) + immutable_graph = self._commit_graph_snapshot(worktree, commits[0]).graph + if immutable_graph.errors: + raise PursuitStoreError( + "invalid_operation", "The committed candidate is not a valid graph.", 422, immutable_graph.errors, + ) + self.store._authenticate_commit(commits[0], self.session_id) + + def _land_commits(self, commits: list[str]) -> None: + if len(commits) != 1 or self.candidate_root is None: + raise PursuitStoreError("write_failed", "Missing validated editor candidate.", 500) + # The shared supervisor holds MemoryWriteLock here. Repeat the HEAD and + # byte fence immediately before publishing the exact validated commit. + dirty = self._dirty_memory_files() + if dirty: + raise MainMemoryDirtyError(dirty) + result = self._run_git( + self.memory_root, "-c", f"core.hooksPath={self.candidate_root / '.git' / 'hooks'}", + "merge", "--ff-only", commits[0], check=False, + ) + if result.returncode: + raise PursuitStoreError("conflict", "The validated edit could not be fast-forwarded. Reload the map.", 409) + if self._git_stdout(self.memory_root, "rev-parse", "HEAD") != commits[0]: + raise PursuitStoreError("write_failed", "The published HEAD does not match the validated editor commit.", 500) + if _status_paths(self, self.memory_root): + raise PursuitStoreError("write_failed", "The root changed immediately after editor publication.", 500) + self.landed_snapshot = self.store.snapshot() + + +def _validate_request_identity(expected_revision: str, session_id: str) -> None: + if not isinstance(expected_revision, str) or _DIGEST_RE.fullmatch(expected_revision) is None: + raise PursuitStoreError("invalid_operation", "A snapshot revision is required.", 422) + if not isinstance(session_id, str) or not session_id or len(session_id) > 1024 or any( + character in session_id for character in "\0\r\n" + ): + raise PursuitStoreError("history_forbidden", "An authenticated editor session is required.", 403) + + +def _session_hash(session_id: str) -> str: + return hashlib.sha256(session_id.encode("utf-8")).hexdigest() + + +def _validation_errors(root: Path) -> list[str]: + validation = MemoryTools(root, role=EDITOR_ROLE).validate_memory(enforce_correction_capacity=False) + if validation.startswith("validation failed:"): + return [line.removeprefix("- ") for line in validation.splitlines()[1:]] + return [] + + +def _status_paths(supervisor: IsolatedWriteSupervisor, root: Path) -> list[str]: + output = supervisor._run_git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout + tokens = output.split("\0") + paths: set[str] = set() + index = 0 + while index < len(tokens): + entry = tokens[index] + index += 1 + if not entry: + continue + if len(entry) < 4: + raise RuntimeError("could not read Git worktree status") + paths.add(entry[3:]) + if "R" in entry[:2] or "C" in entry[:2]: + if index >= len(tokens) or not tokens[index]: + raise RuntimeError("could not read Git rename status") + paths.add(tokens[index]) + index += 1 + return sorted(paths) + + +def _require_relative_path(path: str) -> None: + if not isinstance(path, str) or not path or Path(path).is_absolute() or any( + part in {"", ".", ".."} for part in path.replace("\\", "/").split("/") + ): + raise PursuitStoreError("write_failed", "The editor produced an unsafe file path.", 500) + + +def _reference_changes(before: GraphManifest, after: GraphManifest) -> list[dict[str, Any]]: + repaired: list[dict[str, Any]] = [] + for source_id in sorted(set(before.items) & set(after.items)): + original, current = before.items[source_id], after.items[source_id] + for action, edges in ( + ("removed", [edge for edge in original.edges if edge not in current.edges]), + ("restored", [edge for edge in current.edges if edge not in original.edges]), + ): + for edge_type, target_id in edges: + repaired.append({ + "source_id": source_id, + "source_path": current.file.relative_to(after.root).as_posix(), + "kind": "edge", "target_id": target_id, "edge_type": edge_type, "action": action, + }) + original_focus = {item_id for item_id, _path, _line in before.focus_ids} + current_focus = {item_id for item_id, _path, _line in after.focus_ids} + for action, ids in (("removed", original_focus - current_focus), ("restored", current_focus - original_focus)): + for item_id in sorted(ids): + repaired.append({"source_path": "PURSUITS.md", "kind": "focus", "target_id": item_id, "action": action}) + return repaired diff --git a/rightmemory/pursuit_tree.py b/rightmemory/pursuit_tree.py new file mode 100644 index 0000000..b3e5bf3 --- /dev/null +++ b/rightmemory/pursuit_tree.py @@ -0,0 +1,660 @@ +"""Logical Pursuit editing over the canonical graph index. + +This module has no Git, locking, UI, or agent responsibilities. Its writer is +intended for a transaction's disposable candidate root, never the active root. +""" + +from __future__ import annotations + +import re +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Mapping + +from .graph import ( + ANCHOR_RE, + FOCUS_HEADING_RE, + NODE_RE, + BlockKey, + DocumentBlock, + GraphManifest, + block_body_text, + build_graph_manifest, + heading_title, + remove_edge_targets, + render_heading_line, + replace_heading_title, + validate_heading_title, +) + + +class PursuitOperationError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class PursuitItem: + id: str + title: str + body: str + parent_id: str | None + child_ids: tuple[str, ...] + edges: tuple[tuple[str, str], ...] + focused: bool + source_path: str + source_line: int + anchor_kind: str + editable: bool = True + + +@dataclass(frozen=True, slots=True) +class PursuitTree: + items: dict[str, PursuitItem] + root_ids: tuple[str, ...] + focus_ids: tuple[str, ...] + diagnostics: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + items = [] + for item in self.items.values(): + value = asdict(item) + value["child_ids"] = list(item.child_ids) + value["edges"] = [list(edge) for edge in item.edges] + items.append(value) + return { + "items": items, + "root_ids": list(self.root_ids), + "focus_ids": list(self.focus_ids), + "diagnostics": list(self.diagnostics), + } + + +@dataclass(frozen=True, slots=True) +class PursuitEdit: + changed_paths: tuple[str, ...] + repaired_references: tuple[dict[str, object], ...] + selected_id: str | None + description: str + + +def _block_id(manifest: GraphManifest, block: DocumentBlock) -> str: + if block.item_id is not None: + return block.item_id + relative = block.source_path.relative_to(manifest.root).as_posix() + return f"plain:{relative}:{block.line_number}" + + +def _reserved(manifest: GraphManifest, block: DocumentBlock) -> bool: + if block.kind != "heading": + return False + if FOCUS_HEADING_RE.match(block.line): + return True + return ( + block.source_path == manifest.root / "PURSUITS.md" + and block.depth == 1 + and block.item_id is None + and heading_title(block.line).casefold() == "pursuits" + ) + + +def _node_title(block: DocumentBlock) -> str: + match = NODE_RE.match(block.line) + if match is None: + return block.line.strip() + start = block.line.index("`", block.line.index("`") + 1) + 1 + end = match.start(2) - 1 + suffix = block.line[start:end].rstrip() + for arrow in ("\u2192", "->"): + if suffix.endswith(arrow): + suffix = suffix[:-len(arrow)].rstrip() + break + return suffix.strip() or block.item_id or "Legacy item" + + +def load_pursuit_tree(root: Path) -> PursuitTree: + return _project(build_graph_manifest(Path(root))) + + +def _project(manifest: GraphManifest) -> PursuitTree: + focus_ids = tuple(item_id for item_id, _, _ in manifest.focus_ids) + item_data: dict[str, dict[str, object]] = {} + children: dict[str | None, list[str]] = {None: []} + diagnostics = list(manifest.errors) + seen: set[BlockKey] = set() + + def visit(key: BlockKey, parent_id: str | None) -> None: + if key in seen: + return + seen.add(key) + block = manifest.blocks[key] + current_parent = parent_id + if block.kind in {"heading", "node"} and not _reserved(manifest, block): + item_id = _block_id(manifest, block) + if item_id in item_data: + return # Duplicate ids are already diagnosed; the root is read-only. + graph_item = manifest.items.get(block.item_id or "") + legacy = block.kind == "node" + item_data[item_id] = { + "id": item_id, + "title": _node_title(block) if legacy else heading_title(block.line), + "body": block_body_text(manifest, block).strip("\r\n"), + "parent_id": parent_id, + "edges": graph_item.edges if graph_item is not None else (), + "focused": item_id in focus_ids, + "source_path": block.source_path.relative_to(manifest.root).as_posix(), + "source_line": block.line_number, + "anchor_kind": "node" if legacy else block.anchor_kind or "plain", + "editable": not legacy, + } + children.setdefault(parent_id, []).append(item_id) + children[item_id] = [] + current_parent = item_id + if legacy: + diagnostics.append( + f"Legacy graph node `{item_id}` at " + f"{item_data[item_id]['source_path']}:{block.line_number} is read-only in the map." + ) + for child in block.logical_children: + visit(child, current_parent) + + for key in manifest.root_blocks: + if manifest.blocks[key].family == "pursuit": + visit(key, None) + items = { + item_id: PursuitItem(**value, child_ids=tuple(children[item_id])) + for item_id, value in item_data.items() + } + return PursuitTree(items, tuple(children[None]), focus_ids, tuple(diagnostics)) + + +@dataclass(eq=False) +class _Entry: + kind: str + id: str | None + title: str + anchor_kind: str | None + depth: int + header: str + parts: list[str | _Entry] + document: _Document + parent: _Document | _Entry + block: DocumentBlock | None = None + + +@dataclass(eq=False) +class _Document: + path: Path + newline: str + parts: list[str | _Entry] = field(default_factory=list) + + +def _newline(text: str) -> str: + for line in text.splitlines(keepends=True): + if line.endswith("\r\n"): + return "\r\n" + if line.endswith("\n"): + return "\n" + if line.endswith("\r"): + return "\r" + return "\n" + + +def _line_ending(text: str) -> str: + return text[len(text.rstrip("\r\n")):] + + +def _with_newline(text: str, newline: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", newline) + + +def _render(entry: _Entry | _Document) -> str: + prefix = entry.header if isinstance(entry, _Entry) else "" + return prefix + "".join(part if isinstance(part, str) else _render(part) for part in entry.parts) + + +class _Editor: + def __init__(self, manifest: GraphManifest): + self.manifest = manifest + self.root = manifest.root + self.tree = _project(manifest) + self.documents: dict[Path, _Document] = {} + self.entries: dict[BlockKey, _Entry] = {} + self.items: dict[str, _Entry] = {} + self.used_ids = {item_id.casefold() for item_id in manifest.items} + self.used_paths = {path.name.casefold() for path in self.root.iterdir()} + self.memory_changes: dict[Path, str] = {} + self.repairs: list[dict[str, object]] = [] + for source in manifest.documents.values(): + if source.family != "pursuit": + continue + document = _Document(source.path, _newline(source.text)) + self.documents[source.path] = document + lines = source.text.splitlines(keepends=True) + + def fill(block: DocumentBlock, owner: _Entry | _Document) -> None: + cursor = block.line_number if block.kind != "root" else 0 + for child_key in block.physical_children: + child = manifest.blocks[child_key] + if child.line_number - 1 > cursor: + owner.parts.append("".join(lines[cursor:child.line_number - 1])) + entry = _Entry( + kind=child.kind, + id=_block_id(manifest, child) if child.kind in {"heading", "node"} else None, + title=heading_title(child.line) if child.kind == "heading" else "", + anchor_kind=child.anchor_kind, + depth=child.depth, + header=lines[child.line_number - 1], + parts=[], + document=document, + parent=owner, + block=child, + ) + self.entries[child_key] = entry + if entry.id is not None: + self.items[entry.id] = entry + fill(child, entry) + owner.parts.append(entry) + cursor = child.end_line + if cursor < block.end_line: + owner.parts.append("".join(lines[cursor:block.end_line])) + + fill(manifest.blocks[source.root_key], document) + + def item(self, value: object, *, editable: bool = True) -> _Entry: + if not isinstance(value, str) or value not in self.tree.items: + raise PursuitOperationError("unknown Pursuit item") + entry = self.items[value] + if editable and entry.kind != "heading": + raise PursuitOperationError("legacy graph node leaves are read-only in the map") + return entry + + def new_id(self, title: str) -> str: + ascii_text = "".join(char for char in title.lower() if ord(char) < 128) + slug = re.sub(r"[^a-z0-9]+", "-", ascii_text).strip("-")[:64].rstrip("-") + if not slug or slug == "rules": + slug = "p-" + uuid.uuid4().hex[:12] + candidate = slug + suffix = 2 + while candidate.casefold() in self.used_ids or f"pursuit_{candidate}.md".casefold() in self.used_paths: + candidate = f"{slug}-{suffix}" + suffix += 1 + self.used_ids.add(candidate.casefold()) + return candidate + + def address(self, entry: _Entry, *, title: str | None = None) -> None: + if entry.anchor_kind is not None: + return + old_id = entry.id + entry.id = self.new_id(title or entry.title) + entry.anchor_kind = "#" + entry.header = render_heading_line( + entry.title, "#", entry.id, depth=entry.depth + ) + _line_ending(entry.header) + if old_id is not None: + self.items.pop(old_id, None) + self.items[entry.id] = entry + + def anchor(self, entry: _Entry, kind: str) -> None: + self.address(entry) + match = ANCHOR_RE.match(entry.header.rstrip("\r\n")) + assert match is not None + entry.header = entry.header[:match.start(2)] + kind + entry.header[match.end(2):] + entry.anchor_kind = kind + + def root_container(self) -> _Entry | _Document: + document = self.documents[self.root / "PURSUITS.md"] + for part in document.parts: + if isinstance(part, _Entry) and part.block is not None and _reserved(self.manifest, part.block): + if not FOCUS_HEADING_RE.match(part.header): + return part + return document + + def backing(self, entry: _Entry) -> _Document: + assert entry.id is not None + return self.documents[self.root / f"PURSUIT_{entry.id}.md"] + + def destination(self, parent: _Entry | None) -> _Entry | _Document: + if parent is None: + return self.root_container() + if parent.anchor_kind != "F#" and parent.depth >= 3: + self.split(parent) + return self.backing(parent) if parent.anchor_kind == "F#" else parent + + def split(self, entry: _Entry) -> None: + self.address(entry) + path = self.root / f"PURSUIT_{entry.id}.md" + if path.name.casefold() in self.used_paths: + raise PursuitOperationError(f"cannot create backing file over existing path: {path.name}") + document = _Document(path, entry.document.newline) + self.documents[path] = document + self.used_paths.add(path.name.casefold()) + # Strings in the indexed owner block are its own body, including prose + # around legacy node lines. Keep that prose with the owner when its + # children cross the physical backing boundary. + document.parts = [part for part in entry.parts if isinstance(part, _Entry)] + entry.parts = [part for part in entry.parts if isinstance(part, str)] + self.anchor(entry, "F#") + for part in document.parts: + if isinstance(part, _Entry): + part.parent = document + self.normalize(part, 1, document) + + def normalize(self, entry: _Entry, depth: int, document: _Document) -> None: + if entry.document.newline != document.newline: + entry.header = _with_newline(entry.header, document.newline) + entry.parts = [ + _with_newline(part, document.newline) if isinstance(part, str) else part + for part in entry.parts + ] + entry.document = document + if entry.kind != "heading": + return + if depth > 3: + raise PursuitOperationError("a map heading needs a backing boundary before physical depth four") + if depth != entry.depth: + entry.header = "#" * depth + entry.header[entry.depth:] + entry.depth = depth + physical_children = [part for part in entry.parts if isinstance(part, _Entry)] + if entry.anchor_kind != "F#" and depth == 3 and any(child.kind == "heading" for child in physical_children): + self.split(entry) + return + for child in physical_children: + self.normalize(child, depth + 1, document) + + def insert(self, entry: _Entry, container: _Entry | _Document, after: _Entry | None, *, first: bool) -> None: + if after is not None: + if after.parent is not container: + raise PursuitOperationError("after_id must be a sibling at the destination") + index = container.parts.index(after) + 1 + elif first: + index = next((i for i, part in enumerate(container.parts) if isinstance(part, _Entry) and part.kind != "focus" and not (part.block and _reserved(self.manifest, part.block))), len(container.parts)) + else: + index = len(container.parts) + if after is not None and after.kind == "node": + while index < len(container.parts) and isinstance(container.parts[index], str): + index += 1 + if entry.kind == "heading" and any( + isinstance(part, _Entry) and part.kind == "node" for part in container.parts[index:] + ): + raise PursuitOperationError( + "legacy graph leaves must remain before heading children in their document; insert after them" + ) + document = container if isinstance(container, _Document) else container.document + depth = 1 if isinstance(container, _Document) and container.path.name != "PURSUITS.md" else (container.depth + 1 if isinstance(container, _Entry) else 2) + entry.parent = container + self.normalize(entry, depth, document) + prefix = "".join(part if isinstance(part, str) else _render(part) for part in container.parts[:index]) + if isinstance(container, _Entry): + prefix = container.header + prefix + if prefix and not prefix.endswith(document.newline * 2): + separator = document.newline if prefix.endswith(document.newline) else document.newline * 2 + container.parts.insert(index, separator) + index += 1 + if index < len(container.parts) and not _render(entry).endswith(document.newline * 2): + entry.parts.append(document.newline if _render(entry).endswith(document.newline) else document.newline * 2) + container.parts.insert(index, entry) + + def cleanup_empty(self, container: _Entry | _Document) -> None: + if not isinstance(container, _Document) or container.path.name == "PURSUITS.md": + return + if any(isinstance(part, _Entry) for part in container.parts) or _render(container).strip(): + return + for entry in self.items.values(): + if entry.anchor_kind == "F#" and self.root / f"PURSUIT_{entry.id}.md" == container.path: + self.anchor(entry, "#") + self.documents.pop(container.path, None) + return + + def descendants(self, item_id: str) -> set[str]: + result: set[str] = set() + stack = [item_id] + while stack: + current = stack.pop() + if current in result: + continue + result.add(current) + stack.extend(self.tree.items[current].child_ids) + return result + + def remove_focus(self, deleted: set[str], *, report: bool) -> None: + for key in self.manifest.focus_blocks: + block = self.manifest.blocks[key] + if block.focus_target not in deleted: + continue + entry = self.entries[key] + if entry in entry.parent.parts: + entry.parent.parts.remove(entry) + if report: + self.repairs.append({ + "kind": "focus", "source_id": None, + "source_path": block.source_path.relative_to(self.root).as_posix(), + "target_id": block.focus_target, + }) + + def repair_edges(self, deleted: set[str]) -> None: + memory_lines: dict[Path, list[str]] = {} + for item in self.manifest.items.values(): + if item.id in deleted: + continue + removed = [(kind, target) for kind, target in item.edges if target in deleted] + if not removed: + continue + if item.family == "pursuit": + entry = self.entries[item.block_key] + entry.header = remove_edge_targets(entry.header, deleted) + else: + lines = memory_lines.setdefault(item.file, self.manifest.documents[item.file].text.splitlines(keepends=True)) + lines[item.line_number - 1] = remove_edge_targets(lines[item.line_number - 1], deleted) + for kind, target in removed: + self.repairs.append({ + "kind": "edge", "source_id": item.id, + "source_path": item.file.relative_to(self.root).as_posix(), + "target_id": target, "edge_type": kind, + }) + self.memory_changes.update({path: "".join(lines) for path, lines in memory_lines.items()}) + + def reachable_documents(self) -> set[Path]: + reachable: set[Path] = set() + + def visit(owner: _Entry | _Document) -> None: + if isinstance(owner, _Document): + if owner.path in reachable: + return + reachable.add(owner.path) + for part in owner.parts: + if not isinstance(part, _Entry): + continue + visit(part) + if part.anchor_kind == "F#": + visit(self.backing(part)) + + visit(self.documents[self.root / "PURSUITS.md"]) + return reachable + + def plan(self) -> dict[Path, str | None]: + reachable = self.reachable_documents() + before = {path for path, document in self.manifest.documents.items() if document.family == "pursuit"} + changes: dict[Path, str | None] = {} + for path in before | reachable: + rendered = _render(self.documents[path]) if path in reachable else None + original = self.manifest.documents[path].text if path in before else None + if rendered != original: + changes[path] = rendered + changes.update(self.memory_changes) + return changes + + +def _string(operation: Mapping[str, object], name: str) -> str: + value = operation.get(name) + if not isinstance(value, str): + raise PursuitOperationError(f"{name} must be a string") + return value + + +def _title(operation: Mapping[str, object]) -> str: + try: + return validate_heading_title(_string(operation, "title")) + except ValueError as exc: + raise PursuitOperationError(str(exc)) from exc + + +def _apply(editor: _Editor, operation: Mapping[str, object]) -> tuple[str | None, str]: + kind = operation.get("type") + if kind == "create": + title = _title(operation) + parent = editor.item(operation["parent_id"]) if operation.get("parent_id") is not None else None + after = editor.item(operation["after_id"], editable=False) if operation.get("after_id") is not None else None + expected_parent = operation.get("parent_id") + if after is not None and editor.tree.items[after.id].parent_id != expected_parent: + raise PursuitOperationError("after_id must be a sibling at the destination") + container = editor.destination(parent) + document = container if isinstance(container, _Document) else container.document + item_id = editor.new_id(title) + entry = _Entry("heading", item_id, title, "#", 2, + render_heading_line(title, "#", item_id) + document.newline, + [document.newline], document, container) + editor.items[item_id] = entry + editor.insert(entry, container, after, first="after_id" in operation and after is None) + return item_id, f"Create Pursuit: {title}" + + if kind not in {"rename", "move", "reorder", "delete", "edit_body", "set_focus"}: + raise PursuitOperationError("unknown Pursuit operation") + original_id = _string(operation, "id") + entry = editor.item(original_id) + if kind == "rename": + title = _title(operation) + editor.address(entry, title=title) + entry.header = replace_heading_title(entry.header, title) + entry.title = title + elif kind in {"move", "reorder"}: + parent_id = operation.get("parent_id", editor.tree.items[original_id].parent_id if kind == "reorder" else None) + if parent_id is not None and parent_id in editor.descendants(original_id): + raise PursuitOperationError("cannot move a direction under itself or its descendant") + parent = editor.item(parent_id) if parent_id is not None else None + after = editor.item(operation["after_id"], editable=False) if operation.get("after_id") is not None else None + if after is entry or (after is not None and editor.tree.items[after.id].parent_id != parent_id): + raise PursuitOperationError("after_id must be another sibling at the destination") + editor.address(entry) + container = editor.destination(parent) + previous = entry.parent + previous.parts.remove(entry) + editor.insert(entry, container, after, first="after_id" in operation and after is None) + editor.cleanup_empty(previous) + elif kind == "delete": + deleted = editor.descendants(original_id) + previous = entry.parent + previous.parts.remove(entry) + editor.remove_focus(deleted, report=True) + editor.repair_edges(deleted) + editor.cleanup_empty(previous) + parent_id = editor.tree.items[original_id].parent_id + return parent_id, f"Delete Pursuit subtree: {entry.title}" + elif kind == "edit_body": + body = _string(operation, "body") + newline = entry.document.newline + body = _with_newline(body, newline).strip("\r\n") + if body != _with_newline(editor.tree.items[original_id].body, newline): + editor.address(entry) + first_heading = next((i for i, part in enumerate(entry.parts) if isinstance(part, _Entry) and part.kind == "heading"), len(entry.parts)) + entry.parts[:first_heading] = [newline + body + newline * 2 if body else newline] + elif kind == "set_focus": + focused = operation.get("focused") + if not isinstance(focused, bool): + raise PursuitOperationError("focused must be true or false") + editor.address(entry) + if not focused: + editor.remove_focus({original_id}, report=False) + elif entry.id not in editor.tree.focus_ids: + container = editor.root_container() + document = container if isinstance(container, _Document) else container.document + focus = next((part for part in container.parts if isinstance(part, _Entry) and FOCUS_HEADING_RE.match(part.header.rstrip("\r\n"))), None) + if focus is None: + focus = _Entry("heading", None, "Focus", None, 2, + "## Focus" + document.newline, [document.newline], document, container) + legacy_after = next((part for part in reversed(container.parts) if isinstance(part, _Entry) and part.kind == "node"), None) + editor.insert(focus, container, legacy_after, first=legacy_after is None) + reference = _Entry("focus", None, "", None, 0, + f"- `{entry.id}`" + document.newline, [], document, focus) + last = max((i for i, part in enumerate(focus.parts) if isinstance(part, _Entry) and part.kind == "focus"), default=-1) + if last < 0: + if not _render(focus).endswith(document.newline): + focus.parts.append(document.newline) + focus.parts.append(reference) + focus.parts.append(document.newline) + else: + index = last + 1 + if not _render(focus.parts[last]).endswith(document.newline): + focus.parts.insert(index, document.newline) + index += 1 + focus.parts.insert(index, reference) + return entry.id, f"{str(kind).replace('_', ' ').capitalize()} Pursuit: {entry.title}" + + +def _check_body_edit(before: GraphManifest, after: GraphManifest, original_id: str) -> None: + """Notes may contain Markdown, but cannot silently replace graph structure.""" + old_item = before.items.get(original_id) + # Addressing a pre-existing plain group is an allowed structural change. + added = 0 if old_item is not None else 1 + before_headings = sum(block.kind == "heading" for block in before.blocks.values()) + after_headings = sum(block.kind == "heading" for block in after.blocks.values()) + if before_headings != after_headings or len(after.items) != len(before.items) + added: + raise PursuitOperationError("note text cannot add or remove map headings or graph nodes; use the map editor") + for item_id, item in before.items.items(): + other = after.items.get(item_id) + if other is None: + raise PursuitOperationError("note text cannot remove a graph item") + if item.item_kind == "node": + if before.blocks[item.block_key].line != after.blocks[other.block_key].line: + raise PursuitOperationError("legacy graph node lines are read-only; preserve them when editing the note") + + +def apply_operation(root: Path, operation: Mapping[str, object]) -> PursuitEdit: + """Apply one edit to an isolated candidate root, restoring it on failure. + + The store owns locking, revisions, complete root validation, and publication. + This layer additionally checks the canonical graph before and after rendering. + """ + if not isinstance(operation, Mapping): + raise PursuitOperationError("operation must be an object") + manifest = build_graph_manifest(Path(root)) + if manifest.errors: + raise PursuitOperationError("Pursuit root is read-only until graph errors are repaired: " + "; ".join(manifest.errors)) + editor = _Editor(manifest) + try: + selected_id, description = _apply(editor, operation) + changes = editor.plan() + except (KeyError, TypeError, ValueError) as exc: + if isinstance(exc, PursuitOperationError): + raise + raise PursuitOperationError(str(exc)) from exc + previous: dict[Path, bytes | None] = {} + try: + for path, text in changes.items(): + previous[path] = path.read_bytes() if path.exists() else None + if text is None: + path.unlink() + else: + path.write_bytes(text.encode("utf-8")) + candidate = build_graph_manifest(manifest.root) + if candidate.errors: + raise PursuitOperationError("edit would invalidate the graph: " + "; ".join(candidate.errors)) + if changes and operation.get("type") == "edit_body": + _check_body_edit(manifest, candidate, _string(operation, "id")) + if operation.get("type") == "set_focus": + focused = any(item_id == selected_id for item_id, _, _ in candidate.focus_ids) + if focused != operation["focused"]: + raise PursuitOperationError("the edited document did not retain the requested Focus marker") + except BaseException: + for path, content in previous.items(): + if content is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(content) + raise + return PursuitEdit( + changed_paths=tuple(sorted(path.relative_to(manifest.root).as_posix() for path in changes)), + repaired_references=tuple(editor.repairs), + selected_id=selected_id, + description=description, + ) diff --git a/rightmemory/recent_submitted.py b/rightmemory/recent_submitted.py index da3eeb5..2c6c970 100644 --- a/rightmemory/recent_submitted.py +++ b/rightmemory/recent_submitted.py @@ -14,7 +14,7 @@ RECENT_SUBMITTED_HEADER = "Recent submitted RightMemory candidates" RECENT_SUBMITTED_INTRO = ( - "These are pending updater submissions, not settled Memory or Pursuit. " + "These are pending updater submissions, not settled Memory or Agent Corrections. " "Use relevant entries as short-term continuity while preserving their candidate status." ) diff --git a/rightmemory/reference/MEMORY_RULES.md b/rightmemory/reference/MEMORY_RULES.md index f9436e2..11a029b 100644 --- a/rightmemory/reference/MEMORY_RULES.md +++ b/rightmemory/reference/MEMORY_RULES.md @@ -13,7 +13,7 @@ Store an item in Memory only when all of the following are true: 1. It is expected to remain useful beyond the current task or session. 2. It can materially affect future action, judgment, interpretation, or retrieval. 3. Its scope and meaning can be stated clearly from stored context. -4. It is not better represented only as live Pursuit state. +4. Its value is more than recording a direction in the user's Pursuit map. 5. It is not merely a duplicate of an existing Memory item, project artifact, instruction asset, or Agent Correction. Durability alone is insufficient. A stable but trivial fact is not good Memory. @@ -68,7 +68,7 @@ Use `# User Context` for durable context about the user as a person with ongoing User Context is a compact context profile grounded in evidence. Do not infer personal characteristics beyond what the stored evidence supports. -A commitment that still requires continuation belongs in Pursuit even when it also reflects a long-term direction. +A direction's place in the Pursuit map is the user's decision. An unfinished commitment is not automatically Memory or Pursuit; preserve independently durable context here and leave execution continuity in project artifacts. ## Cross-Session Agent Behavior diff --git a/rightmemory/reference/PURSUIT_RULES.md b/rightmemory/reference/PURSUIT_RULES.md index dab47f2..1fe6336 100644 --- a/rightmemory/reference/PURSUIT_RULES.md +++ b/rightmemory/reference/PURSUIT_RULES.md @@ -2,89 +2,46 @@ ## Purpose -A Pursuit is live intent organized into a hierarchy: what should become true, why it matters, and how the pursuit currently decomposes into active directions. It is neither a backlog, a work log, nor a detailed execution record. Task duration does not determine whether something belongs in Pursuit. +A Pursuit is an ongoing direction the user wants represented in a hierarchical map. It preserves the direction's identity, place in the map, and the context needed to understand or enter it. -A candidate belongs in Pursuit when the intent should remain part of the active or deliberately parked pursuit structure after the current update. Incompleteness alone is insufficient. +The map is user-owned. An unfinished task, newly mentioned idea, or available next step does not by itself establish a Pursuit. Project execution details belong in project documents, Git, tests, issues, experiments, and task conversations. -Pursuit preserves the objective, its meaning, its position in the hierarchy, and the current direction needed to understand it. Detailed execution state and instructions for resuming project work belong primarily in project-local artifacts. +Pursuit is not a backlog, work log, task execution store, or record of completed work. ## Structure ```md ## {#} → [] - + -**State:** - -**Next:** -- `do` -- `ask` -- `wait` - -**Done when:** +### {#} ``` -A deliberately parked Pursuit additionally uses: - -```md -**Status:** parked -``` - -Rules: - -- The heading body states the objective and why it matters. -- State is optional when the objective and Next already preserve enough context to understand the pursuit's present position or direction. -- A Pursuit must remain interpretable from stored context under the model's reading-context rules. -- An active leaf has an ordered Next list, normally one to three items. Its first valid item is the default movement. -- A parent may omit Next when an active child contains the movement. -- Next records the current movement, not a detailed resume plan. Refer to project-local artifacts when execution detail matters. It grants no authority beyond the current task and environment. -- Use `Done when` only when there is a clear observable endpoint. -- Use `Status: parked` only when later reconsideration is still intended and the Pursuit is deliberately outside current movement. -- Tree nesting expresses decomposition. Add edges only for useful relationships not already clear from containment. -- Keep one canonical Pursuit for each intent. Never copy its State or Next into another node. - -## State Quality - -Good State is the minimum current context required to understand the pursuit's present position, constraints, or direction. - -Supporting evidence, completed history, detailed experiments, implementation narration, and operational resume instructions belong in project artifacts or durable Memory when they independently satisfy Memory admission. - -State should describe what is true now. Do not preserve every transition that led there. - -## Admission And Removal - -Work that starts and finishes within one reconciled candidate thread normally leaves no Pursuit. +Every new map item is an addressable heading with a title, stable id, and tree position. The body is optional free-form Markdown. Meaning comes from the title, ancestry, body, and useful graph relationships; there are no node types or required progress, next-action, completion, or status fields. -Create or retain a Pursuit when an objective remains meaningfully active, under reconsideration, deliberately parked, blocked, waiting, handed off, or otherwise part of the pursuit hierarchy being carried forward. +Tree nesting expresses decomposition and supplies reading context. Add an edge for a useful relationship that containment does not already express. Short project, file, or person context may appear naturally in the nearest relevant ancestor body. Independently reusable context belongs in Memory when it passes Memory admission. This does not introduce fixed context fields or another registry. -Do not create a Pursuit merely because a task is unfinished or because detailed work may need to resume later. Project-local artifacts should preserve that operational continuity. +Canonical storage is `PURSUITS.md` and its reachable `PURSUIT_.md` detail files. Logical hierarchy continues recursively through `F#`; those boundaries arrange physical storage and carry no additional semantic meaning. The editor hides ids, heading depth, and document boundaries during ordinary map editing. The shared schema's `####` terminal-reference rule still applies inside each file. -A parked Pursuit remains only while future reconsideration is genuinely intended. Importance alone does not justify keeping inactive intent. +## Ownership -When a Pursuit completes, is abandoned, or is superseded: +There are two normal entrances for semantic map changes: the human-facing editor and the explicitly invoked `maintain-pursuit-map` workflow. Creating, deleting, renaming, merging, reorganizing, and focusing directions are decisions owned by the user. -1. preserve only independently durable consequences in Memory; -2. repair affected references and Focus entries; -3. remove the terminal Pursuit from the active tree. +A precise requested map edit is authorized directly. Broad cleanup or ambiguous reorganization requires a concise proposed tree change and approval before editing. -Git history preserves its former state. Pursuit should not accumulate completed history. +Update, ordinary orchestration, review, pruning, insight generation, and acting agents may read Pursuit as context. They do not infer, submit, or apply map changes from task progress, blockage, waiting, next steps, handoff, or completion. Sync repair may reconcile already-authorized map state within its bounded repair transaction; it does not choose new directions for the user. ## Focus -`## Focus` lists the Pursuits currently receiving attention or expected to guide near-term work when no newer user instruction takes precedence. +`## Focus` is an ordered list of Pursuit heading ids, each written in backticks. It marks current attention, not an execution queue, importance ranking, or permission to act. -Each entry is a backticked id of an addressable Pursuit heading. Order expresses the default attention order, not a global ranking of importance. +The editor presents Focus as a small marker on the map. A direction can remain in the map without being focused. Removing a direction also removes its Focus reference. -Important active Pursuits may remain outside Focus. Parked Pursuits do not appear in Focus. +## Removal -## Maintenance +When the user decides a direction is completed, abandoned, or superseded, preserve only consequences that independently qualify for Memory, repair Focus and graph references, and remove the direction from the live map. Memory maintenance remains separately authorized; map deletion does not authorize unrelated Memory edits. -When acting from Pursuit: +Git history preserves earlier map states. Do not accumulate completed, archived, or parked statuses. A user who wants to retain a direction for later may place it under a natural branch such as “Later” or explain the intended meaning in its body. -1. Read the selected Pursuit, relevant ancestors and active descendants, and reachable Memory context. -2. Verify claims that may have changed against current files, tests, tools, or external reality. -3. Reconsider the Pursuit when assumptions fail, cost changes materially, it becomes infeasible, or a better direction replaces it. -4. If it remains valid, perform the first valid `do`, surface an `ask` rather than guessing, or check the condition behind a `wait`. -5. After material work, update the canonical node and remove stale State or Next content. -6. Update ancestors only when their objective, direction, blockage, or completion changed. +Deleting a map node removes its logical subtree. Repair typed edges targeting the deleted ids in both graph trees and remove unreachable Pursuit backing files; leave prose mentions and unrelated content unchanged. diff --git a/rightmemory/reference/rightmemory-schema.md b/rightmemory/reference/rightmemory-schema.md index 2c11a5a..c43c2e6 100644 --- a/rightmemory/reference/rightmemory-schema.md +++ b/rightmemory/reference/rightmemory-schema.md @@ -5,7 +5,7 @@ RightMemory has three semantic modules: - **Memory** stores durable context that should remain useful beyond the current task or session. -- **Pursuit** stores ongoing or deliberately parked pursuits and goals, their hierarchy, and the context needed to understand their current position and direction. +- **Pursuit** is a user-owned hierarchical map of ongoing directions, with the context needed to understand or enter them. - **Agent Corrections** stores bounded, reusable cases in which the user redirected prior agent work. Memory and Pursuit form one addressable graph organized into two Markdown document trees. Their headings and nodes share one globally unique id namespace, and typed edges may cross between the trees. @@ -42,7 +42,7 @@ Addressable headings use: ### Provider Question View {MQ#heading-id} → [edge1, edge2, ...] ``` -Plain tree headings without graph relationships may omit the anchor and edge list. +Plain tree headings without graph relationships may omit the anchor and edge list. New Pursuit map items use addressable headings; their bodies are ordinary optional Markdown, with no dedicated task fields or node types. Addressable `#`, `##`, and `###` headings may have body paragraphs directly below them. The body describes the heading concept as a whole. Use child nodes only for independently useful items. @@ -59,7 +59,6 @@ Rules: - Edges may connect any two addressable graph items. - Useful but unsettled Memory begins its description with `Uncertain:`. - Focus entries in `PURSUITS.md` reference Pursuit heading ids; they are not graph nodes. -- Bullets under a Pursuit's `**Next:**` block are ordered control entries; they are not graph nodes. ## Backing Forms @@ -70,7 +69,7 @@ Rules: - Memory: `MEMORY_.md` - Pursuit: `PURSUIT_.md` -The logical ancestor chain continues across the detail-file boundary. +The logical ancestor chain continues recursively across detail-file boundaries. The Pursuit editor presents this as one tree and hides the physical document split; each graph file still obeys the heading and terminal-reference rules below. ### M#: Markdown Evidence diff --git a/rightmemory/tools.py b/rightmemory/tools.py index e1e7c71..5875b21 100644 --- a/rightmemory/tools.py +++ b/rightmemory/tools.py @@ -13,7 +13,6 @@ validate_corrections_markdown, ) from .graph import ( - PURSUIT_ACTION_RE, PURSUIT_DETAIL_FILE_RE, build_graph_manifest, resolve_backing_reference, @@ -58,7 +57,8 @@ GIT_REVISION_RE = re.compile(r"^[A-Za-z0-9_.^~/-]+$") PRUNE_SUBJECT_PREFIX = "prune:" ACTIVE_MEMORY_ROLES = {"dreamer", "pruner", "reviewer", "sync-reconciler", "update"} -FULL_RIGHTMEMORY_WRITE_ROLES = {"update"} +UPDATE_WRITE_ROLES = {"update"} +PURSUIT_MAP_WRITE_ROLES = {"pursuit-map"} CORRECTIONS_READ_ROLES = {"update", "sync-reconciler"} INSIGHT_ROLES = {"insight"} RETRIEVE_ROLES = {"retrieve"} @@ -1414,16 +1414,17 @@ def _write_policy_label(self) -> str: ) if self.role in SHARED_VIEW_BUILDER_ROLES: return "shared_views/ source files" - if self.role in FULL_RIGHTMEMORY_WRITE_ROLES: + if self.role in PURSUIT_MAP_WRITE_ROLES: + return "PURSUITS.md or PURSUIT_*.md files" + if self.role in UPDATE_WRITE_ROLES: return ( - "MEMORY.md, ordinary MEMORY_*.md, PURSUITS.md, PURSUIT_*.md, " - "or fixed Agent Correction collections" + "MEMORY.md, ordinary MEMORY_*.md, or fixed Agent Correction collections" ) return "MEMORY.md or ordinary MEMORY_*.md files" def _is_allowed_write_path(self, relative_path: str) -> bool: if relative_path in FIXED_CORRECTION_COLLECTION_PATHS: - return self.role in FULL_RIGHTMEMORY_WRITE_ROLES | SYNC_RECONCILER_ROLES + return self.role in UPDATE_WRITE_ROLES | SYNC_RECONCILER_ROLES if self.role in INSIGHT_ROLES: return self._is_insight_log_path(relative_path) if self.role in SYNC_RECONCILER_ROLES: @@ -1437,8 +1438,8 @@ def _is_allowed_write_path(self, relative_path: str) -> bool: ) if self.role in SHARED_VIEW_BUILDER_ROLES: return self._is_shared_view_definition_path(relative_path) - if self.role in FULL_RIGHTMEMORY_WRITE_ROLES: - return self._is_active_rightmemory_path(relative_path) + if self.role in PURSUIT_MAP_WRITE_ROLES: + return self._is_active_pursuit_path(relative_path) return self._is_active_memory_path(relative_path) def _allowed_write_path(self, path: str) -> str: diff --git a/rightmemory/web/app.py b/rightmemory/web/app.py index 4d952af..ea6afb2 100644 --- a/rightmemory/web/app.py +++ b/rightmemory/web/app.py @@ -8,6 +8,7 @@ import uvicorn from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status +from starlette.concurrency import run_in_threadpool from .auth import ( clear_session_cookie, @@ -23,6 +24,7 @@ from .models import error_detail, ok_response from .process import MANAGED_WEB_ENV, clear_web_process_files, consume_web_stop_request, register_web_process from .service import WebStudioService, resolve_allowed_memory_root +from ..pursuit_store import PursuitStoreError from ..shared_view_questions import question_response_payload, verify_question_view_token @@ -48,7 +50,10 @@ async def index(): @app.get("/static/{asset_name}") async def static_asset(asset_name: str): - return _static_file_response(static_root, asset_name, allowed={"app.js", "styles.css"}) + return _static_file_response( + static_root, asset_name, + allowed={"app.js", "styles.css", "pursuit-map.js", "pursuit-map.css", "pursuit-map.LICENSE.txt"}, + ) @app.get("/api/session") async def session(request: Request): @@ -87,6 +92,65 @@ async def overview(service=Depends(current_service)): async def status_api(service=Depends(current_service)): return ok_response("status loaded", service.status()) + async def pursuit_response(service, message, action, *args): + try: + data = await run_in_threadpool(action, *args) + except PursuitStoreError as exc: + detail = error_detail(str(exc)) + detail.update(code=exc.code, diagnostics=list(exc.diagnostics)) + if exc.status in {409, 422}: + try: + detail["snapshot"] = await run_in_threadpool(service.pursuit_map) + except (OSError, ValueError, RuntimeError): + # A concurrent filesystem failure must not hide the original conflict. + pass + raise HTTPException(status_code=exc.status, detail=detail) from exc + except ValueError as exc: + detail = error_detail(str(exc)) + detail.update(code="invalid_request", diagnostics=[]) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc + return ok_response(message, data) + + @app.get("/api/pursuit-map") + async def pursuit_map(service=Depends(current_service)): + return await pursuit_response(service, "pursuit map loaded", service.pursuit_map) + + @app.post("/api/pursuit-map/operations") + async def apply_pursuit_operation( + request: Request, + payload: dict[str, object] = Body(...), + session=Depends(current_session), + ): + require_csrf(root, request, request.headers.get("x-csrf-token")) + service = service_for_active_root(session.active_root) + return await pursuit_response( + service, "pursuit map updated", service.apply_pursuit_operation, payload, session.session_id, + ) + + @app.post("/api/pursuit-map/undo") + async def undo_pursuit_operation( + request: Request, + payload: dict[str, object] = Body(...), + session=Depends(current_session), + ): + require_csrf(root, request, request.headers.get("x-csrf-token")) + service = service_for_active_root(session.active_root) + return await pursuit_response( + service, "pursuit map operation undone", service.undo_pursuit_operation, payload, session.session_id, + ) + + @app.post("/api/pursuit-map/redo") + async def redo_pursuit_operation( + request: Request, + payload: dict[str, object] = Body(...), + session=Depends(current_session), + ): + require_csrf(root, request, request.headers.get("x-csrf-token")) + service = service_for_active_root(session.active_root) + return await pursuit_response( + service, "pursuit map operation redone", service.redo_pursuit_operation, payload, session.session_id, + ) + @app.get("/api/settings") async def settings_api(service=Depends(current_service)): return ok_response("settings loaded", service.settings()) @@ -491,6 +555,7 @@ def _static_file_response(static_root: Path, asset_name: str, *, allowed: set[st ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", + ".txt": "text/plain; charset=utf-8", } return Response(content=path.read_bytes(), media_type=media_types.get(path.suffix, "application/octet-stream")) @@ -506,14 +571,19 @@ def main(argv: list[str] | None = None) -> int: parser.error("--serve is required") root = args.memory_root.resolve() server = uvicorn.Server(uvicorn.Config(create_web_app(root), host=args.host, port=args.port)) - if os.environ.get(MANAGED_WEB_ENV) == "1": - register_web_process(root, os.getpid(), ready=True) + managed = os.environ.get(MANAGED_WEB_ENV) == "1" + if managed: + register_web_process(root, os.getpid()) def monitor_stop_request() -> None: + ready = False while not server.should_exit: if consume_web_stop_request(root, os.getpid()): server.should_exit = True return + if managed and server.started and not ready: + register_web_process(root, os.getpid(), ready=True) + ready = True time.sleep(0.1) monitor = threading.Thread(target=monitor_stop_request, name="rightmemory-web-stop", daemon=True) diff --git a/rightmemory/web/frontend/.gitignore b/rightmemory/web/frontend/.gitignore new file mode 100644 index 0000000..256e54c --- /dev/null +++ b/rightmemory/web/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.preview/ diff --git a/rightmemory/web/frontend/build.mjs b/rightmemory/web/frontend/build.mjs new file mode 100644 index 0000000..ae5c4c9 --- /dev/null +++ b/rightmemory/web/frontend/build.mjs @@ -0,0 +1,27 @@ +import { build, context } from 'esbuild'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; + +const preview = process.argv.includes('--serve'); +const options = { + entryPoints: [preview ? 'tests/browser.ts' : 'src/pursuit-map.ts'], + outfile: preview ? '.preview/browser.js' : '../static/pursuit-map.js', + bundle: true, + format: 'esm', + target: ['es2022'], + minify: !preview, + sourcemap: preview, + legalComments: 'none', + logLevel: 'info', +}; +if (preview) { + await mkdir('.preview', { recursive: true }); + await copyFile('tests/browser.html', '.preview/index.html'); + const runner = await context(options); + await runner.watch(); + const server = await runner.serve({ servedir: '.preview', host: '127.0.0.1', port: 8767 }); + console.log(`Frontend fixture: http://127.0.0.1:${server.port}`); +} else { + await build(options); + const license = await readFile('node_modules/mind-elixir/LICENSE', 'utf8'); + await writeFile('../static/pursuit-map.LICENSE.txt', `Mind Elixir 5.15.1\nhttps://github.com/SSShooter/mind-elixir-core\n\n${license}`); +} diff --git a/rightmemory/web/frontend/package-lock.json b/rightmemory/web/frontend/package-lock.json new file mode 100644 index 0000000..c322552 --- /dev/null +++ b/rightmemory/web/frontend/package-lock.json @@ -0,0 +1,598 @@ +{ + "name": "rightmemory-pursuit-map", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rightmemory-pursuit-map", + "dependencies": { + "mind-elixir": "5.15.1" + }, + "devDependencies": { + "@types/node": "24.3.0", + "esbuild": "0.25.9", + "tsx": "4.20.5", + "typescript": "5.9.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/mind-elixir": { + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/mind-elixir/-/mind-elixir-5.15.1.tgz", + "integrity": "sha512-Lm/IfNC3jnxqcchKsjkaX/arXDvgPvpPT+BLfTH2Kl1taO9+RPtv/jrCevn1LqPzHoWL2Yl4QeP/xFpjZuZFHw==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.20.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", + "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/rightmemory/web/frontend/package.json b/rightmemory/web/frontend/package.json new file mode 100644 index 0000000..0fdc44e --- /dev/null +++ b/rightmemory/web/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "rightmemory-pursuit-map", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs", + "dev": "node build.mjs --serve", + "test": "tsx --test tests/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "mind-elixir": "5.15.1" + }, + "devDependencies": { + "@types/node": "24.3.0", + "esbuild": "0.25.9", + "tsx": "4.20.5", + "typescript": "5.9.2" + } +} diff --git a/rightmemory/web/frontend/src/drafts.ts b/rightmemory/web/frontend/src/drafts.ts new file mode 100644 index 0000000..608de5a --- /dev/null +++ b/rightmemory/web/frontend/src/drafts.ts @@ -0,0 +1,72 @@ +import { remapOperation, type Operation, type Snapshot } from './tree.ts'; + +export interface TitleDraft { + id: string; + text: string; + operation: Extract; + temporaryId?: string; + error?: string; +} + +export interface NoteDraft { + id: string; + text: string; + savedText: string; + saving: boolean; + error?: string; +} + +/** Unsaved semantic text stays in memory, separate from persisted browser view state. */ +export class DraftBook { + title: TitleDraft | null = null; + failedTitles: TitleDraft[] = []; + savingTitles: TitleDraft[] = []; + note: NoteDraft | null = null; + + get dirty(): boolean { + return !!this.title || !!this.failedTitles.length || !!this.savingTitles.length || !!this.note && (this.note.text !== this.note.savedText || this.note.saving); + } + + failedTitle(draft: TitleDraft, error: string): void { + this.savingTitles = this.savingTitles.filter((saving) => saving !== draft); + this.failedTitles = this.failedTitles.filter((failed) => failed.id !== draft.id); + this.failedTitles.push({ ...draft, error }); + } + + openNote(id: string, body: string): NoteDraft { + this.note = { id, text: body, savedText: body, saving: false }; + return this.note; + } + + noteSaved(id: string, submitted: string): void { + if (this.note?.id !== id) return; + this.note.savedText = submitted; + this.note.saving = false; + this.note.error = undefined; + // An edit typed during the request remains dirty; it is never replaced by the reply. + } + + noteFailed(id: string, message: string): void { + if (this.note?.id !== id) return; + this.note.saving = false; + this.note.error = message; + } + + reconcile(snapshot: Snapshot): void { + const note = this.note; + if (!note || note.saving || note.text !== note.savedText || note.error) return; + const item = snapshot.items.find((item) => item.id === note.id); + if (item) note.text = note.savedText = item.body; + } + + remap(from: string, to: string): void { + const update = (draft: TitleDraft) => { + if (draft.id === from) draft.id = to; + draft.operation = remapOperation(draft.operation, from, to) as TitleDraft['operation']; + }; + if (this.title) update(this.title); + this.failedTitles.forEach(update); + this.savingTitles.forEach(update); + if (this.note?.id === from) this.note.id = to; + } +} diff --git a/rightmemory/web/frontend/src/pursuit-map.css b/rightmemory/web/frontend/src/pursuit-map.css new file mode 100644 index 0000000..df847a5 --- /dev/null +++ b/rightmemory/web/frontend/src/pursuit-map.css @@ -0,0 +1,185 @@ +.pursuit-map { + --pm-ink: #30453e; + --pm-muted: #78837e; + --pm-border: #e2e7e0; + --pm-accent: #347d6a; + display: flex; + flex-direction: column; + min-height: 480px; + height: calc(100dvh - 116px); + overflow: hidden; + color: var(--pm-ink); + background: #fdfdfb; + border: 1px solid var(--pm-border); + border-radius: 10px; + font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; +} +.pursuit-map[hidden], .pursuit-map [hidden] { display: none !important; } +.pursuit-map button, .pursuit-map input, .pursuit-map textarea { font: inherit; } +.pursuit-map button { + min-width: 30px; + min-height: 30px; + padding: 4px 9px; + color: var(--pm-ink); + background: transparent; + border: 1px solid transparent; + border-radius: 5px; + cursor: pointer; + white-space: nowrap; +} +.pursuit-map button:hover, .pursuit-map summary:hover { background: #edf2ec; border-color: transparent; } +.pursuit-map button:disabled { opacity: .35; cursor: default; } +.pursuit-map button:focus-visible, .pursuit-map summary:focus-visible { outline: 2px solid var(--pm-accent); outline-offset: 2px; } +.pursuit-map input, .pursuit-map textarea { box-sizing: border-box; } +.pm-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 15px; + padding: 8px 12px; + background: #fff; + border-bottom: 1px solid var(--pm-border); + z-index: 35; +} +.pm-tool-group { display: flex; align-items: center; gap: 3px; } +.pm-view-tools { margin-left: auto; } +.pm-divider { width: 1px; height: 17px; background: var(--pm-border); margin: 0 5px; } +.pm-save-status { color: var(--pm-muted); font-size: 11px; margin-right: 7px; white-space: nowrap; } +.pm-save-status::before { content: ''; display: inline-block; width: 5px; height: 5px; margin-right: 6px; border-radius: 50%; background: #7aa189; vertical-align: 2px; } +.pm-save-status.pm-saving::before { background: #c08d45; animation: pm-pulse 1s ease-in-out infinite alternate; } +@keyframes pm-pulse { to { opacity: .35; } } +.pm-toolbar [aria-pressed='true'] { color: #a67228; background: #f7f0df; } +.pm-more, .pm-help { position: relative; } +.pm-more summary, .pm-help summary { display: grid; place-items: center; height: 30px; min-width: 30px; border-radius: 5px; cursor: pointer; list-style: none; } +.pm-more summary::-webkit-details-marker, .pm-help summary::-webkit-details-marker { display: none; } +.pm-more summary { font-weight: 700; font-size: 18px; letter-spacing: 1px; } +.pm-help summary { font-size: 12px; color: var(--pm-muted); } +.pm-menu, .pm-help > div { + position: absolute; + top: 37px; + right: 0; + min-width: 235px; + padding: 7px; + background: white; + border: 1px solid var(--pm-border); + box-shadow: 0 8px 28px #223d241a; + border-radius: 8px; + z-index: 60; +} +.pm-menu { display: grid; gap: 2px; } +.pm-menu button { display: flex; justify-content: space-between; gap: 15px; text-align: left; } +.pm-menu kbd { color: var(--pm-muted); font: inherit; font-size: 11px; } +.pm-menu .pm-danger { color: #a64f3e; } +.pm-help > div { width: 320px; padding: 16px; } +.pm-help strong { display: block; margin-bottom: 7px; } +.pm-help p { margin: 8px 0; color: var(--pm-muted); font-size: 12px; line-height: 1.6; } +.pm-stage { flex: 1; position: relative; min-height: 0; overflow: hidden; } +.pm-canvas { width: 100%; height: 100%; } +.pm-canvas .map-container { font-family: inherit; background: #fdfdfb; } +.pm-canvas .map-container me-root { margin: 30px 5px; } +.pm-canvas .map-container me-root me-tpc { font-size: 25px; font-weight: 650; border: 0; padding: 10px 15px; } +.pm-canvas .map-container me-main > me-wrapper > me-parent { margin: 8px 4px; } +.pm-canvas .map-container me-main > me-wrapper > me-parent > me-tpc { padding: 8px 14px; } +.pm-canvas .map-container me-tpc { max-width: 34em; line-height: 1.4; border-radius: 5px; } +.pm-canvas .map-container me-tpc.selected { outline: 2px solid #4d8d7b; outline-offset: 2px; } +.pm-canvas .map-container me-tpc.pm-readonly { color: #7d847e !important; border-bottom: 1px dashed #c3cac1 !important; } +.pm-canvas .map-container me-tpc.pm-search-hit { box-shadow: 0 0 0 4px #f2d88883; } +.pm-canvas .map-container me-epd { width: 15px; height: 15px; opacity: .38; cursor: pointer; } +.pm-canvas .map-container me-epd.minus { opacity: .15; } +.pm-canvas .map-container me-parent:hover > me-epd, .pm-canvas .map-container me-epd:hover { opacity: .85; } +.pm-canvas .map-container #input-box { outline: 2px solid var(--pm-accent); min-height: 28px; white-space: pre; max-width: 45em; background: #fff !important; color: var(--pm-ink); box-shadow: 0 3px 12px #263a2820; } +.pm-canvas .map-container .pm-node-marker { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + min-height: 18px; + margin: -2px -2px -2px 4px; + padding: 0 2px; + border: 0; + border-radius: 3px; + color: #8d9e91; + font: 12px/1 sans-serif; + vertical-align: 1px; + pointer-events: auto; +} +.pm-canvas .map-container [data-map-marker='focus'] { color: #b48938; } +.pm-canvas .map-container .insert-preview.show { background: #4a8c7775; outline: 1px solid #357763; } +.pm-canvas .map-container .insert-preview.before, .pm-canvas .map-container .insert-preview.after { height: 4px; background: #397b65; opacity: 1; } +.pm-canvas .map-container .insert-preview.before { top: -7px; } +.pm-canvas .map-container .insert-preview.after { bottom: -7px; } +.pm-canvas .map-container .mind-elixir-ghost { background: #f4f8f1; border: 1px solid #658c75; padding: 5px 10px; font-size: 14px; } +.pm-canvas-hint { position: absolute; bottom: 12px; left: 17px; margin: 0; color: #9ba39b; font-size: 11px; pointer-events: none; } +.pm-canvas-hint span { margin-left: 18px; } +.pm-zoom { position: absolute; right: 14px; bottom: 12px; display: flex; align-items: center; gap: 2px; background: #ffffffed; border: 1px solid var(--pm-border); border-radius: 7px; } +.pm-zoom span { min-width: 38px; text-align: center; color: var(--pm-muted); font-size: 11px; } +.pm-search { + position: absolute; + top: 13px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 3px; + width: min(450px, calc(100% - 25px)); + background: #fff; + border: 1px solid #c9d8cb; + border-radius: 8px; + box-shadow: 0 5px 24px #2039211c; + padding: 5px 7px; + z-index: 31; +} +.pm-search input { width: 100%; min-width: 70px; min-height: 30px; padding: 4px 6px; border: 0; background: transparent; outline: none; color: var(--pm-ink); } +.pm-search-count { flex-shrink: 0; font-size: 11px; color: var(--pm-muted); } +.pm-note, .pm-relations, .pm-recovery { + position: absolute; + z-index: 32; + top: 14px; + right: 14px; + width: min(365px, calc(100% - 28px)); + background: white; + border: 1px solid #d4dfd2; + border-radius: 10px; + box-shadow: 0 9px 36px #29452a22; +} +.pm-note { display: flex; flex-direction: column; height: min(510px, calc(100% - 64px)); } +.pm-note header, .pm-relations header { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; padding: 13px 13px 11px 17px; border-bottom: 1px solid #edf0e9; } +.pm-note header small { color: #8c998d; letter-spacing: .1em; font-size: 9px; } +.pm-note h2 { margin: 4px 0 0; font-size: 15px; line-height: 1.5; font-weight: 550; word-break: break-word; } +.pm-note textarea { flex: 1; min-height: 110px; width: 100%; border: 0; border-radius: 0; padding: 16px; resize: none; background: #fff; color: #35453b; outline: none; line-height: 1.75; font: 13px/1.7 ui-monospace, SFMono-Regular, Consolas, 'Microsoft YaHei', monospace; } +.pm-note textarea:read-only { background: #fafbf8; } +.pm-note-status { padding: 5px 15px; color: var(--pm-muted); font-size: 11px; } +.pm-note-status.pm-error { background: #fbf1e5; color: #9a623c; } +.pm-note footer { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; padding: 9px 12px; border-top: 1px solid #edf0e9; } +.pm-note footer span { flex: 1; color: #96a091; font-size: 9px; } +.pm-note footer [data-command='save-note'] { color: white; background: #407d67; } +.pm-note footer [data-command='discard-note'] { font-size: 11px; color: #996346; } +.pm-relations { width: min(295px, calc(100% - 28px)); } +.pm-relations header { align-items: center; padding: 9px 12px; } +.pm-relations > div { padding: 10px 14px; display: grid; gap: 3px; color: var(--pm-muted); font-size: 12px; } +.pm-relations > div button { text-align: left; white-space: normal; } +.pm-recovery { left: 14px; right: auto; padding: 12px; max-height: 45%; overflow: auto; } +.pm-recovery strong { font-size: 12px; color: #9b663b; } +.pm-recovery > div { display: flex; align-items: center; gap: 4px; margin-top: 8px; } +.pm-recovery > div span { flex: 1; font-size: 12px; overflow-wrap: anywhere; user-select: text; } +.pm-recovery button { color: #9b663b; font-size: 11px; border-color: #eee1d0; } +.pm-toast { position: absolute; bottom: 53px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; max-width: min(720px, calc(100% - 28px)); padding: 8px 10px 8px 15px; border: 1px solid #ccd7c9; border-radius: 8px; color: #42523f; background: #fffef6; box-shadow: 0 5px 20px #2b3c2720; z-index: 50; font-size: 12px; } +.pm-toast span { overflow-wrap: anywhere; } +.pm-toast button { color: #397965; font-weight: 600; } +.pm-diagnostics { display: flex; gap: 20px; align-items: flex-start; padding: 9px 15px; background: #fcf5e7; color: #8f6637; font-size: 12px; border-bottom: 1px solid #f1e6d1; } +.pm-diagnostics details { min-width: 0; } +.pm-diagnostics summary { cursor: pointer; } +.pm-diagnostics p { color: inherit; margin: 6px 0; overflow-wrap: anywhere; } +@media (max-width: 1000px) { + .pm-toolbar { gap: 3px 6px; padding: 7px; } + .pm-node-tools button span { display: none; } + .pm-canvas-hint span:last-child { display: none; } +} +@media (max-width: 680px) { + .pursuit-map { height: calc(100dvh - 160px); min-height: 440px; border-radius: 0; } + .pm-toolbar { font-size: 12px; } + .pm-view-tools { margin-left: 0; } + .pm-canvas-hint { font-size: 10px; } + .pm-canvas-hint span { display: none; } +} +@media (prefers-reduced-motion: reduce) { .pm-save-status.pm-saving::before { animation: none; } } diff --git a/rightmemory/web/frontend/src/pursuit-map.ts b/rightmemory/web/frontend/src/pursuit-map.ts new file mode 100644 index 0000000..99859a0 --- /dev/null +++ b/rightmemory/web/frontend/src/pursuit-map.ts @@ -0,0 +1,519 @@ +import { ApiError, MutationQueue, type QueueChange, type Transport } from './queue.ts'; +import { applyOperation, childrenOf, deletionSelection, DRAFT_PREFIX, indexTree, promoteOperation, VIRTUAL_ROOT, + type MutationResult, type Operation, type Snapshot } from './tree.ts'; +import { DraftBook, type TitleDraft } from './drafts.ts'; +import { MapRenderer } from './renderer.ts'; +import { keyboardCommand, navigate, readView, reconcileView, reveal, writeView, type ViewState } from './view-state.ts'; +import './pursuit-map.css'; + +type FetchJson = (path: string, options?: RequestInit) => Promise<{ data: unknown }>; + +export function apiTransport(fetchJson: FetchJson): Transport { + const request = async (path: string, body?: unknown): Promise => { + try { + const response = await fetchJson(`/api/pursuit-map${path}`, body === undefined ? undefined : { method: 'POST', body: JSON.stringify(body) }); + return response.data as T; + } catch (cause) { + const error = cause as Error & { status?: number; detail?: { snapshot?: Snapshot } }; + throw new ApiError(error.message, error.status ?? 0, error.detail?.snapshot); + } + }; + return { + load: () => request(''), + mutate: (expected_revision, operation) => request('/operations', { expected_revision, operation }), + history: (kind, expected_revision, commit) => request(`/${kind}`, { expected_revision, commit }), + }; +} + +const markup = ` + + +
+
+ + + + + +
100%
+

Enter · sibling Tab · child Double-click · rename

+
+`; + +export interface PursuitMapController { + refresh(): Promise; + setActive(active: boolean): void; + readonly hasUnsavedChanges: boolean; + destroy(): void; +} + +export async function mountPursuitMap(host: HTMLElement, fetchJson: FetchJson): Promise { + return mountMap(host, apiTransport(fetchJson)); +} + +/** Exported separately so the browser fixture exercises the complete UI with disposable state. */ +export async function mountMap(host: HTMLElement, transport: Transport): Promise { + const snapshot = await transport.load(); + return new PursuitMap(host, snapshot, transport); +} + +class PursuitMap implements PursuitMapController { + private queue: MutationQueue; + private renderer: MapRenderer; + private view: ViewState; + private drafts = new DraftBook(); + private abort = new AbortController(); + private active = true; + private matches: string[] = []; + private matchIndex = -1; + private toastTimer: ReturnType | undefined; + private unsubscribe: () => void; + + constructor(private host: HTMLElement, snapshot: Snapshot, transport: Transport) { + host.className = 'pursuit-map'; + host.innerHTML = markup; + this.queue = new MutationQueue(snapshot, transport); + this.view = reconcileView(snapshot, readView(localStorage, snapshot.root_key)); + this.renderer = new MapRenderer(this.$('.pm-canvas'), snapshot, this.view, { + select: (id) => { this.view.selected = id; this.saveView(); this.updateTools(); }, + collapse: (id, collapsed) => this.setCollapsed(id, collapsed), + move: (operation) => { void this.mutate(operation); }, + editStart: (id, title) => this.editStarted(id, title), + editInput: (text) => { if (this.drafts.title) this.drafts.title.text = text; }, + editEnd: (id, text, canceled) => this.editEnded(id, text, canceled), + marker: (id, kind) => { this.select(id); void this.command(kind); }, + viewport: (viewport) => { this.view.viewport = viewport; this.$('.pm-zoom span').textContent = `${Math.round(viewport.scale * 100)}%`; this.saveView(); }, + error: (message) => this.toast(message), + }); + this.unsubscribe = this.queue.subscribe((change) => this.changed(change)); + host.addEventListener('click', (event) => { + const button = (event.target as HTMLElement).closest('button[data-command]'); + if (button?.disabled) return; + if (button) { this.$('.pm-more').open = false; void this.command(button.dataset.command!); } + }, { signal: this.abort.signal }); + this.$('.pm-search input').addEventListener('input', () => this.search(), { signal: this.abort.signal }); + this.$('.pm-search input').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { event.preventDefault(); this.nextMatch(event.shiftKey ? -1 : 1); } + if (event.key === 'Escape') { event.preventDefault(); this.closeSearch(); } + }, { signal: this.abort.signal }); + this.$('.pm-note textarea').addEventListener('input', (event) => { + if (this.drafts.note) this.drafts.note.text = (event.target as HTMLTextAreaElement).value; + this.updateNote(false); + }, { signal: this.abort.signal }); + document.addEventListener('keydown', (event) => this.keydown(event), { capture: true, signal: this.abort.signal }); + window.addEventListener('beforeunload', (event) => { + if (this.queue.pendingCount || this.drafts.dirty) { event.preventDefault(); event.returnValue = ''; } + }, { signal: this.abort.signal }); + window.addEventListener('focus', () => { + if (this.active && !this.renderer.editing) void this.refresh().catch((error) => this.toast(error.message)); + }, { signal: this.abort.signal }); + this.$('.pm-toast-close').addEventListener('click', () => { this.$('.pm-toast').hidden = true; }, { signal: this.abort.signal }); + this.updateTools(); + this.renderDiagnostics(snapshot); + requestAnimationFrame(() => this.renderer.focus()); + } + + private $(selector: string): T { return this.host.querySelector(selector)!; } + private saveView(): void { writeView(localStorage, this.queue.snapshot.root_key, this.view); } + private get snapshot(): Snapshot { return this.queue.snapshot; } + private get selected() { return indexTree(this.snapshot).get(this.view.selected ?? ''); } + + private displaySnapshot(): Snapshot { + const draft = this.drafts.title; + if (draft?.operation.type === 'create') { + try { return applyOperation(this.snapshot, { ...draft.operation, title: draft.text || 'New direction' }, draft.id); } + catch { return this.snapshot; } + } + return this.snapshot; + } + + private render(): void { + const display = this.displaySnapshot(); + this.view = reconcileView(display, this.view); + this.renderer.render(display, this.view); + this.renderer.highlight(new Set(this.matches)); + this.updateTools(); + this.saveView(); + } + + private changed(change: QueueChange): void { + if (change.remapped) { + const { from, to } = change.remapped; + if (this.view.selected === from) this.view.selected = to; + this.view.collapsed = this.view.collapsed.map((id) => id === from ? to : id); + this.drafts.remap(from, to); + } + this.drafts.reconcile(change.snapshot); + this.render(); + this.updateNote(); + this.renderDiagnostics(change.snapshot); + if (change.error) this.toast(`${change.error.message} The map was reloaded; unsaved text is kept.`, undefined, 0); + } + + private updateTools(): void { + const item = this.selected; + const writable = this.snapshot.writable; + this.$('[data-command="undo"]').disabled = !this.queue.canUndo; + this.$('[data-command="redo"]').disabled = !this.queue.canRedo; + for (const command of ['rename', 'delete', 'promote', 'focus']) this.$(`[data-command="${command}"]`).disabled = !writable || !item?.editable; + this.$('[data-command="note"]').disabled = !item; + this.$('[data-command="child"]').disabled = !writable || !!item && !item.editable; + this.$('[data-command="sibling"]').disabled = !writable; + this.$('[data-command="root"]').disabled = !writable; + this.$('[data-command="collapse"]').disabled = !item?.child_ids.length; + this.$('[data-command="focus"]').setAttribute('aria-pressed', String(!!item?.focused)); + const status = this.$('.pm-save-status'); + status.textContent = this.queue.pendingCount ? `Saving${this.queue.pendingCount > 1 ? ` (${this.queue.pendingCount})` : '…'}` : writable ? 'Saved' : 'Read-only'; + status.classList.toggle('pm-saving', !!this.queue.pendingCount); + } + + private renderDiagnostics(snapshot: Snapshot): void { + const banner = this.$('.pm-diagnostics'); + banner.replaceChildren(); + banner.hidden = snapshot.writable && !snapshot.diagnostics.length; + if (banner.hidden) return; + const title = document.createElement('strong'); + title.textContent = snapshot.writable ? 'Map notices' : 'This map is read-only'; + banner.append(title); + const details = document.createElement('details'); + const summary = document.createElement('summary'); + summary.textContent = 'Show diagnostics'; + details.append(summary); + for (const diagnostic of snapshot.diagnostics) { + const paragraph = document.createElement('p'); + paragraph.textContent = typeof diagnostic === 'string' ? diagnostic : diagnostic.message ?? JSON.stringify(diagnostic); + details.append(paragraph); + } + banner.append(details); + } + + private select(id: string, center = false): void { + this.view = reveal(this.displaySnapshot(), this.view, id); + this.render(); + this.renderer.select(id, center); + } + + private setCollapsed(id: string, collapsed: boolean): void { + this.view.collapsed = this.view.collapsed.filter((entry) => entry !== id); + if (collapsed) this.view.collapsed.push(id); + this.render(); + } + + private async command(command: string): Promise { + try { + switch (command) { + case 'child': this.create('child'); break; + case 'sibling': this.create('sibling'); break; + case 'root': this.create('root'); break; + case 'rename': if (this.selected?.editable) await this.renderer.beginEdit(this.selected.id); break; + case 'promote': { + const operation = this.view.selected ? promoteOperation(this.snapshot, this.view.selected) : null; + if (operation) await this.mutate(operation); break; + } + case 'delete': await this.remove(); break; + case 'focus': if (this.selected?.editable) await this.mutate({ type: 'set_focus', id: this.selected.id, focused: !this.selected.focused }); break; + case 'collapse': if (this.selected?.child_ids.length) this.setCollapsed(this.selected.id, !this.view.collapsed.includes(this.selected.id)); break; + case 'note': this.openNote(); break; + case 'save-note': await this.saveNote(); break; + case 'close-note': if (await this.saveNote()) { this.$('.pm-note').hidden = true; this.drafts.note = null; this.renderer.focus(); } break; + case 'discard-note': this.drafts.note = null; this.$('.pm-note').hidden = true; this.renderer.focus(); break; + case 'relations': this.openRelations(); break; + case 'close-relations': this.$('.pm-relations').hidden = true; this.renderer.focus(); break; + case 'search': this.$('.pm-search').hidden = false; this.$('.pm-search input').focus(); this.$('.pm-search input').select(); break; + case 'close-search': this.closeSearch(); break; + case 'next-result': this.nextMatch(1); break; + case 'previous-result': this.nextMatch(-1); break; + case 'fit': this.renderer.fit(); this.renderer.focus(); break; + case 'zoom-in': this.renderer.zoom(0.1); break; + case 'zoom-out': this.renderer.zoom(-0.1); break; + case 'undo': case 'redo': { + const result = await this.queue.history(command); + if (result?.selected_id) this.select(result.selected_id, true); + if (result) this.toast(command === 'undo' ? 'Undone.' : 'Redone.'); + break; + } + } + } catch (error) { this.toast((error as Error).message, undefined, 0); } + } + + private keydown(event: KeyboardEvent): void { + if (!this.active) return; + const target = event.target as HTMLElement; + const isInput = target.matches('input,textarea,[contenteditable]'); + if (target.id === 'input-box') return; // The library owns IME, Enter and Escape while renaming. + const command = keyboardCommand(event, isInput); + if (!command) return; + if (isInput && command === 'save' && target.closest('.pm-note')) { event.preventDefault(); void this.command('save-note'); return; } + if (isInput) return; + // Shortcuts operate on the canvas, not the surrounding Web Studio navigation. + if (!target.closest('.pm-canvas') && !['search', 'undo', 'redo', 'fit', 'escape'].includes(command)) return; + event.preventDefault(); + event.stopPropagation(); + if (command === 'navigate') { + this.view = navigate(this.snapshot, this.view, event.key); + this.render(); + this.renderer.select(this.view.selected, true); + } else if (command === 'escape') { + this.closeSearch(); + this.$('.pm-relations').hidden = true; + this.$('.pm-more').open = false; + this.$('.pm-help').open = false; + } else void this.command(command); + } + + private create(kind: 'child' | 'sibling' | 'root', previous?: TitleDraft): void { + if (!this.snapshot.writable || this.renderer.editing) return; + const selected = this.selected; + const parent = kind === 'root' ? null : kind === 'child' ? selected?.id ?? null : selected?.parent_id ?? null; + const siblings = childrenOf(this.snapshot, parent); + const after = kind === 'sibling' && selected ? selected.id : siblings.at(-1) ?? null; + const id = previous?.id ?? `${DRAFT_PREFIX}${crypto.randomUUID()}`; + const operation = previous?.operation.type === 'create' ? previous.operation : { type: 'create' as const, parent_id: parent, after_id: after, title: '' }; + this.drafts.title = previous ?? { id, temporaryId: id, text: '', operation }; + if (operation.parent_id) this.view = reveal(this.snapshot, this.view, operation.parent_id); + this.view.collapsed = this.view.collapsed.filter((entry) => entry !== operation.parent_id); + this.view.selected = id; + this.render(); + void this.renderer.beginEdit(id, this.drafts.title.text); + } + + private editStarted(id: string, text: string): void { + if (this.drafts.title?.id !== id) this.drafts.title = { id, text, operation: { type: 'rename', id, title: text } }; + } + + private editEnded(id: string, text: string, canceled: boolean): void { + const draft = this.drafts.title; + if (!draft || draft.id !== id) return; + this.drafts.title = null; + if (canceled || !text) { this.render(); return; } + if (draft.operation.type === 'rename' && indexTree(this.snapshot).get(id)?.title === text) { this.render(); return; } + draft.text = text; + draft.operation = { ...draft.operation, title: text }; + this.drafts.savingTitles.push(draft); + void this.queue.enqueue(draft.operation, draft.temporaryId).then(() => { + this.drafts.savingTitles = this.drafts.savingTitles.filter((saving) => saving !== draft); + }).catch((error: Error) => { + this.drafts.failedTitle(draft, error.message); + this.render(); + this.renderRecovery(); + }); + this.renderer.focus(); + } + + private renderRecovery(): void { + const panel = this.$('.pm-recovery'); + panel.replaceChildren(); + panel.hidden = !this.drafts.failedTitles.length; + if (panel.hidden) return; + const title = document.createElement('strong'); + title.textContent = 'Unsaved titles kept here'; + panel.append(title); + for (const draft of this.drafts.failedTitles) { + const row = document.createElement('div'); + const text = document.createElement('span'); + text.textContent = draft.text; + text.title = draft.error ?? ''; + const retry = document.createElement('button'); + retry.type = 'button'; retry.textContent = 'Edit again'; + retry.addEventListener('click', () => { + if (this.renderer.editing) return; + const operation = draft.operation; + const items = indexTree(this.snapshot); + if (operation.type === 'create' && operation.parent_id && !items.has(operation.parent_id) || operation.type === 'rename' && !items.has(operation.id)) { + this.toast('That direction was removed elsewhere. The unsaved title is still here for you to copy.', undefined, 0); return; + } + this.drafts.failedTitles = this.drafts.failedTitles.filter((entry) => entry !== draft); + if (operation.type === 'create') this.create('child', draft); + else { this.drafts.title = draft; this.select(draft.id, true); void this.renderer.beginEdit(draft.id, draft.text); } + this.renderRecovery(); + }); + const discard = document.createElement('button'); + discard.type = 'button'; discard.textContent = 'Discard'; + discard.addEventListener('click', () => { this.drafts.failedTitles = this.drafts.failedTitles.filter((entry) => entry !== draft); this.renderRecovery(); }); + row.append(text, retry, discard); panel.append(row); + } + } + + private async mutate(operation: Operation): Promise { + try { return await this.queue.enqueue(operation); } + catch (error) { this.toast((error as Error).message, undefined, 0); return null; } + } + + private async remove(): Promise { + const item = this.selected; + if (!item?.editable || !this.snapshot.writable) return; + this.view.selected = deletionSelection(this.snapshot, item.id); + const promise = this.queue.enqueue({ type: 'delete', id: item.id }); + const undo = async () => { + try { + const result = await promise; + if (this.queue.canUndo && this.snapshot.git_head === result.commit) await this.command('undo'); + else this.toast('Newer edits exist. Use the toolbar Undo to undo them in order.'); + } catch { /* Failure already restored the deleted direction. */ } + }; + this.toast(`“${item.title}” removed.`, { label: 'Undo', action: () => { void undo(); } }, 0); + try { + const result = await promise; + if (result.repaired_references.length) this.toast(`“${item.title}” removed; broken references repaired.`, { label: 'Undo', action: () => { void undo(); } }, 0); + } catch { /* The queue reports failure and restores the authoritative snapshot. */ } + } + + private openNote(): void { + const item = this.selected; + if (!item) return; + const existing = this.drafts.note; + if (existing && existing.id !== item.id && (existing.saving || existing.text !== existing.savedText)) { + this.toast('Save or discard the open note before opening another.'); this.$('.pm-note textarea').focus(); return; + } + if (existing?.id !== item.id) this.drafts.openNote(item.id, item.body); + this.$('.pm-note').hidden = false; + this.updateNote(); + this.$('.pm-note textarea').focus(); + } + + private updateNote(updateText = true): void { + const note = this.drafts.note; + if (!note) return; + const item = indexTree(this.snapshot).get(note.id); + this.$('.pm-note h2').textContent = item?.title ?? 'Direction removed elsewhere'; + const textarea = this.$('.pm-note textarea'); + if (updateText && textarea.value !== note.text) textarea.value = note.text; + textarea.readOnly = !item?.editable || !this.snapshot.writable; + const status = this.$('.pm-note-status'); + status.textContent = note.error ? `${note.error} Your text is kept. Review the map, then save again.` : note.saving ? 'Saving…' : note.text !== note.savedText ? 'Unsaved changes' : 'Saved'; + status.classList.toggle('pm-error', !!note.error); + this.$('[data-command="save-note"]').disabled = note.saving || !item?.editable || !this.snapshot.writable; + this.$('[data-command="discard-note"]').hidden = note.text === note.savedText && !note.error; + } + + private async saveNote(): Promise { + const note = this.drafts.note; + if (!note) return true; + if (note.saving) return false; + if (note.text === note.savedText && !note.error) return true; + const submitted = note.text; + note.saving = true; + this.updateNote(false); + try { + const result = await this.queue.enqueue({ type: 'edit_body', id: note.id, body: submitted }); + this.drafts.noteSaved(result.selected_id ?? note.id, submitted); + this.updateNote(false); + return this.drafts.note?.text === submitted; + } catch (error) { + this.drafts.noteFailed(note.id, (error as Error).message); + this.updateNote(false); + return false; + } + } + + private openRelations(): void { + const item = this.selected; + if (!item) return; + const panel = this.$('.pm-relations'); + const content = this.$('.pm-relations > div'); + content.replaceChildren(); + const labels: Record = { ref: 'Reference', depends: 'Depends on', supports: 'Supports', contradicts: 'Contradicts', related: 'Related to' }; + const items = indexTree(this.snapshot); + for (const [kind, target] of item.edges) { + const linked = items.get(target); + const line = document.createElement(linked ? 'button' : 'p'); + line.textContent = `${labels[kind] ?? 'Related'} · ${linked?.title ?? 'Memory reference'}`; + if (linked) line.addEventListener('click', () => this.select(linked.id, true)); + content.append(line); + } + if (!item.edges.length) content.textContent = 'No related directions.'; + panel.hidden = false; + } + + private search(): void { + const query = this.$('.pm-search input').value.trim().toLocaleLowerCase(); + this.matches = query ? this.snapshot.items.filter((item) => `${item.title}\n${item.body}`.toLocaleLowerCase().includes(query)).map((item) => item.id) : []; + this.matchIndex = -1; + this.nextMatch(1); + } + + private nextMatch(delta: number): void { + if (this.matches.length) { + this.matchIndex = (this.matchIndex + delta + this.matches.length) % this.matches.length; + this.select(this.matches[this.matchIndex], true); + } + this.$('.pm-search-count').textContent = this.matches.length ? `${this.matchIndex + 1} / ${this.matches.length}` : this.$('.pm-search input').value ? 'No results' : ''; + this.renderer.highlight(new Set(this.matches)); + } + + private closeSearch(): void { + this.$('.pm-search').hidden = true; + this.matches = []; + this.renderer.highlight(new Set()); + this.renderer.focus(); + } + + private toast(message: string, action?: { label: string; action(): void }, duration = 6000): void { + clearTimeout(this.toastTimer); + const toast = this.$('.pm-toast'); + toast.hidden = false; + this.$('.pm-toast > span').textContent = message; + const button = this.$('.pm-toast > button:not(.pm-toast-close)'); + button.hidden = !action; + button.textContent = action?.label ?? ''; + button.onclick = action?.action ?? null; + if (duration) this.toastTimer = setTimeout(() => { toast.hidden = true; }, duration); + } + + async refresh(): Promise { await this.queue.reload(); } + get hasUnsavedChanges(): boolean { return this.queue.pendingCount > 0 || this.drafts.dirty; } + setActive(active: boolean): void { + this.active = active; + if (active) { this.render(); this.renderer.focus(); } + else this.saveView(); + } + destroy(): void { + this.unsubscribe(); + clearTimeout(this.toastTimer); + this.abort.abort(); + this.renderer.destroy(); + } +} diff --git a/rightmemory/web/frontend/src/queue.ts b/rightmemory/web/frontend/src/queue.ts new file mode 100644 index 0000000..1403946 --- /dev/null +++ b/rightmemory/web/frontend/src/queue.ts @@ -0,0 +1,160 @@ +import { applyOperation, remapOperation, type MutationResult, type Operation, type Snapshot } from './tree.ts'; + +export interface Transport { + load(): Promise; + mutate(revision: string, operation: Operation): Promise; + history(kind: 'undo' | 'redo', revision: string, commit: string): Promise; +} + +export class ApiError extends Error { + constructor(message: string, public status = 0, public snapshot?: Snapshot) { super(message); } +} + +interface Pending { + operation: Operation; + temporaryId?: string; + resolve: (result: MutationResult) => void; + reject: (error: Error) => void; +} + +export interface QueueChange { + snapshot: Snapshot; + pending: number; + error?: Error; + remapped?: { from: string; to: string }; + result?: MutationResult; +} + +/** One request in flight. Reapply unconfirmed edits over every authoritative reply. */ +export class MutationQueue { + private confirmed: Snapshot; + private pending: Pending[] = []; + private running = false; + private historyBusy = false; + private recovering = false; + private listeners = new Set<(change: QueueChange) => void>(); + private undoStack: string[] = []; + private redoStack: string[] = []; + constructor(snapshot: Snapshot, private transport: Transport) { this.confirmed = snapshot; } + + get snapshot(): Snapshot { + return this.pending.reduce((tree, entry) => applyOperation(tree, entry.operation, entry.temporaryId), this.confirmed); + } + get pendingCount(): number { return this.pending.length + Number(this.historyBusy || this.recovering); } + get canUndo(): boolean { return !!this.undoStack.length && !this.pendingCount; } + get canRedo(): boolean { return !!this.redoStack.length && !this.pendingCount; } + + subscribe(listener: (change: QueueChange) => void): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + private emit(extra: Omit = {}): void { + const change = { snapshot: this.snapshot, pending: this.pendingCount, ...extra }; + this.listeners.forEach((listener) => listener(change)); + } + + enqueue(operation: Operation, temporaryId?: string): Promise { + if (!this.confirmed.writable) return Promise.reject(new Error('The map is read-only. Resolve its diagnostics before editing.')); + if (this.historyBusy) return Promise.reject(new Error('Wait for undo or redo to finish.')); + if (this.recovering) return Promise.reject(new Error('Wait for the map to reload after the failed save.')); + try { applyOperation(this.snapshot, operation, temporaryId); } catch (error) { return Promise.reject(error); } + const promise = new Promise((resolve, reject) => { + this.pending.push({ operation, temporaryId, resolve, reject }); + }); + this.emit(); + void this.drain(); + return promise; + } + + async reload(): Promise { + if (this.pendingCount) return; + const snapshot = await this.transport.load(); + if (this.pendingCount) return; // An edit may have started during the read. + if (snapshot.root_key !== this.confirmed.root_key) throw new Error('The active root changed. Reopen the Pursuit Map.'); + if (snapshot.revision !== this.confirmed.revision) { + this.undoStack = []; + this.redoStack = []; + } + this.confirmed = snapshot; + this.emit(); + } + + private async drain(): Promise { + if (this.running) return; + this.running = true; + try { + while (this.pending.length) { + const entry = this.pending[0]; + try { + const result = await this.transport.mutate(this.confirmed.revision, entry.operation); + if (result.snapshot.root_key !== this.confirmed.root_key) throw new Error('The active root changed. Reopen the Pursuit Map before editing.'); + let remapped: QueueChange['remapped']; + if (entry.operation.type === 'create' && entry.temporaryId) { + const created = result.selected_id ?? result.snapshot.items.find((item) => !this.confirmed.items.some((old) => old.id === item.id))?.id; + if (!created) throw new Error('The saved direction was not returned by the server. Reload before continuing.'); + remapped = { from: entry.temporaryId, to: created }; + } else if ('id' in entry.operation) { + const previousId = entry.operation.id; + if (result.selected_id && result.selected_id !== previousId && !result.snapshot.items.some((item) => item.id === previousId)) { + remapped = { from: previousId, to: result.selected_id }; + } + } + this.pending.shift(); + this.confirmed = result.snapshot; + if (remapped) this.pending.forEach((pending) => { pending.operation = remapOperation(pending.operation, remapped!.from, remapped!.to); }); + if (result.undoable && result.commit) this.undoStack.push(result.commit); + if (result.commit) this.redoStack = []; + entry.resolve(result); + this.emit({ result, remapped }); + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + const abandoned = this.pending.splice(0); + this.undoStack = []; + this.redoStack = []; + this.recovering = true; + await this.recover(error); + this.recovering = false; + this.emit({ error }); + abandoned.forEach((pending) => pending.reject(error)); + } + } + } finally { this.running = false; } + } + + private async recover(error: Error): Promise { + if (error instanceof ApiError && error.snapshot?.root_key === this.confirmed.root_key) { + this.confirmed = error.snapshot; + return; + } + try { + const latest = await this.transport.load(); + if (latest.root_key === this.confirmed.root_key) this.confirmed = latest; + } catch { /* Keep the last confirmed tree; never replay uncertain writes automatically. */ } + } + + async history(kind: 'undo' | 'redo'): Promise { + const source = kind === 'undo' ? this.undoStack : this.redoStack; + const destination = kind === 'undo' ? this.redoStack : this.undoStack; + if (!source.length || this.pendingCount) return null; + this.historyBusy = true; + this.emit(); + try { + const result = await this.transport.history(kind, this.confirmed.revision, source.at(-1)!); + source.pop(); + if (result.commit) destination.push(result.commit); + this.confirmed = result.snapshot; + this.historyBusy = false; + this.emit({ result }); + return result; + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + this.undoStack = []; + this.redoStack = []; + await this.recover(error); + this.historyBusy = false; + this.emit({ error }); + throw error; + } + } +} diff --git a/rightmemory/web/frontend/src/renderer.ts b/rightmemory/web/frontend/src/renderer.ts new file mode 100644 index 0000000..d6a6fbb --- /dev/null +++ b/rightmemory/web/frontend/src/renderer.ts @@ -0,0 +1,311 @@ +import MindElixir, { type MindElixirData, type NodeObj, type Topic } from 'mind-elixir'; +import 'mind-elixir/style.css'; +import { assertMove, childrenOf, indexTree, VIRTUAL_ROOT, visualRoot, type Operation, type Snapshot } from './tree.ts'; +import { type ViewState } from './view-state.ts'; + +const palette = ['#d58375', '#c79561', '#79a494', '#9694b9', '#80a7b8', '#b895ae']; + +export function canvasData(snapshot: Snapshot, view: ViewState): MindElixirData { + const items = indexTree(snapshot); + const folded = new Set(view.collapsed); + const build = (id: string, depth: number, color: string): NodeObj => { + const item = items.get(id)!; + return { + id, topic: item.title, expanded: !folded.has(id), branchColor: color, + style: depth === 0 + ? { color: '#243633', fontSize: '25px', background: '#ffffff', border: 'none', fontWeight: '650' } + : depth === 1 + ? { color: '#293c37', background: `${color}26`, border: 'none', fontSize: '16px', fontWeight: '550' } + : { color: '#35453f', background: depth === 2 ? `${color}14` : 'transparent', border: 'none', fontSize: '14px' }, + children: depth === 0 && folded.has(id) ? [] : item.child_ids.map((child, childIndex) => build(child, depth + 1, depth === 0 ? palette[childIndex % palette.length] : color)), + }; + }; + const root = visualRoot(snapshot); + return { + nodeData: root === VIRTUAL_ROOT + ? { id: root, topic: 'Pursuits', children: snapshot.root_ids.map((id, index) => build(id, 1, palette[index % palette.length])) } + : build(root, 0, palette[0]), + direction: MindElixir.RIGHT, + }; +} + +interface Callbacks { + select(id: string): void; + collapse(id: string, collapsed: boolean): void; + move(operation: Operation): void; + editStart(id: string, title: string): void; + editInput(text: string): void; + editEnd(id: string, text: string, canceled: boolean): void; + marker(id: string, kind: 'note' | 'focus' | 'relations'): void; + viewport(viewport: NonNullable): void; + error(message: string): void; +} + +/** All library-specific DOM, layout, drag and viewport behavior stays here. */ +export class MapRenderer { + private mind: MindElixir; + private snapshot: Snapshot; + private view: ViewState; + private rendering = false; + private editingId: string | null = null; + private editText: string | undefined; + private abort = new AbortController(); + private viewportTimer: ReturnType | undefined; + private renderDeferred = false; + + constructor(private host: HTMLElement, snapshot: Snapshot, view: ViewState, private callbacks: Callbacks) { + this.snapshot = snapshot; + this.view = view; + this.mind = new MindElixir({ + el: host, direction: MindElixir.RIGHT, contextMenu: false, toolBar: false, + keypress: false, allowUndo: false, alignment: 'nodes', scaleMin: 0.1, scaleMax: 2, + // In Mind Elixir this option also gates pointer handlers; clip the stage with CSS instead. + overflowHidden: false, + mouseSelectionButton: 0, + theme: { + name: 'Pursuit', type: 'light', palette, + cssVar: { + '--bgcolor': '#fdfdfb', '--color': '#35453f', '--selected': '#3d8172', + '--main-gap-x': '25px', '--main-gap-y': '13px', '--node-gap-x': '18px', '--node-gap-y': '2px', + '--main-bgcolor': '#f5f6f1', '--main-color': '#35453f', '--main-border': 'none', + '--root-bgcolor': '#ffffff', '--root-color': '#243633', '--root-border-color': 'transparent', + '--root-radius': '7px', '--main-radius': '6px', '--topic-padding': '5px 8px', '--map-padding': '60px', + }, + }, + before: { + beginEdit: (topic) => !!topic && this.canEdit(topic.nodeObj.id), + moveNodeIn: (from, to) => this.allowDrop(from, to, 'in'), + moveNodeBefore: (from, to) => this.allowDrop(from, to, 'before'), + moveNodeAfter: (from, to) => this.allowDrop(from, to, 'after'), + // Creation and deletion go through the independent reducer, including a real visual root. + addChild: () => false, insertSibling: () => false, insertParent: () => false, + removeNodes: () => false, copyNode: () => false, copyNodes: () => false, + }, + }); + this.mind.bus.addListener('selectNodes', (nodes) => { + if (this.rendering || !nodes.length) return; + const selected = nodes.at(-1)!; + if (nodes.length > 1) this.mind.selectNode(this.mind.findEle(selected.id)); + this.callbacks.select(selected.id); + this.decorate(); + }); + this.mind.bus.addListener('expandNode', (node) => { + this.callbacks.collapse(node.id, node.expanded === false); + this.decorate(); + }); + this.mind.bus.addListener('operation', (operation) => { + if (operation.name === 'beginEdit') this.attachInlineEditor(operation.obj.id); + else if (['moveNodeIn', 'moveNodeBefore', 'moveNodeAfter'].includes(operation.name) && 'objs' in operation) { + for (const node of operation.objs) { + const parent = node.parent?.id === VIRTUAL_ROOT ? null : node.parent?.id ?? null; + const siblings = node.parent?.children ?? []; + const after = siblings[siblings.indexOf(node) - 1]?.id ?? null; + this.callbacks.move({ type: 'move', id: node.id, parent_id: parent, after_id: after }); + } + } + }); + this.mind.bus.addListener('scale', () => this.saveViewport()); + this.mind.bus.addListener('move', () => this.saveViewport()); + this.mind.init(canvasData(snapshot, view)); + this.mind.container.setAttribute('role', 'tree'); + this.mind.container.setAttribute('aria-label', 'Pursuit directions. Enter adds a sibling; Tab adds a child.'); + this.mind.container.setAttribute('aria-describedby', 'pm-keyboard-hint'); + host.addEventListener('click', (event) => { + const target = (event.target as HTMLElement).closest('[data-map-marker]'); + if (!target) return; + event.stopPropagation(); + const topic = target.closest('me-tpc') as Topic; + if (target.dataset.mapMarker === 'collapse') { + this.callbacks.collapse(topic.nodeObj.id, !this.view.collapsed.includes(topic.nodeObj.id)); + return; + } + this.callbacks.marker(topic.nodeObj.id, target.dataset.mapMarker as 'note' | 'focus' | 'relations'); + }, { capture: true, signal: this.abort.signal }); + host.addEventListener('pointerdown', (event) => { + if ((event.target as HTMLElement).closest('[data-map-marker]')) event.stopPropagation(); + }, { capture: true, signal: this.abort.signal }); + this.restoreViewport(view.viewport); + this.decorate(); + this.select(view.selected); + } + + get editing(): boolean { return this.editingId !== null; } + + private canEdit(id: string): boolean { + return this.snapshot.writable && id !== VIRTUAL_ROOT && !!indexTree(this.snapshot).get(id)?.editable; + } + + private allowDrop(from: Topic[], to: Topic, position: 'in' | 'before' | 'after'): boolean { + if (from.length !== 1 || !this.snapshot.writable || this.editing) return false; + const id = from[0].nodeObj.id; + const target = to.nodeObj.id; + const items = indexTree(this.snapshot); + const parent = position === 'in' ? (target === VIRTUAL_ROOT ? null : target) : items.get(target)?.parent_id ?? null; + const siblings = childrenOf(this.snapshot, parent).filter((entry) => entry !== id); + const after = position === 'in' ? siblings.at(-1) ?? null : position === 'after' ? target : siblings[siblings.indexOf(target) - 1] ?? null; + try { assertMove(this.snapshot, id, parent, after); return true; } + catch (error) { this.callbacks.error((error as Error).message); return false; } + } + + render(snapshot: Snapshot, view: ViewState): void { + this.snapshot = snapshot; + this.view = view; + if (this.editing) { this.renderDeferred = true; return; } + this.rendering = true; + const viewport = this.getViewport(); + this.mind.refresh(canvasData(snapshot, view)); + this.mind.editable = snapshot.writable; + this.restoreViewport(viewport); + this.decorate(); + this.select(view.selected); + this.rendering = false; + } + + private decorate(): void { + const items = indexTree(this.snapshot); + for (const topic of this.host.querySelectorAll('me-tpc')) { + const id = topic.nodeObj.id; + const item = items.get(id); + topic.setAttribute('role', 'treeitem'); + topic.setAttribute('aria-label', item?.title ?? 'Pursuits'); + topic.setAttribute('aria-selected', String(this.view.selected === id)); + topic.id = `pm-node-${id}`; + topic.tabIndex = -1; + topic.classList.toggle('pm-readonly', item?.editable === false); + const level = (node: NodeObj): number => node.parent ? 1 + level(node.parent) : 1; + topic.setAttribute('aria-level', String(level(topic.nodeObj))); + if (item?.child_ids.length) topic.setAttribute('aria-expanded', String(!this.view.collapsed.includes(id))); + topic.querySelectorAll('[data-map-marker]').forEach((marker) => marker.remove()); + const markers: Array<['note' | 'focus' | 'relations', string, string]> = []; + if (item?.body) markers.push(['note', '▤', 'Open note']); + if (item?.focused) markers.push(['focus', '✦', 'Remove Focus']); + if (item?.edges.length) markers.push(['relations', '↗', 'Show related directions']); + for (const [kind, icon, label] of markers) { + const marker = document.createElement('button'); + marker.type = 'button'; + marker.tabIndex = -1; + marker.dataset.mapMarker = kind; + marker.className = 'pm-node-marker'; + marker.textContent = icon; + marker.title = label; + marker.setAttribute('aria-label', `${label}: ${item!.title}`); + topic.append(marker); + } + if (topic.parentElement?.tagName === 'ME-ROOT' && item?.child_ids.length) { + const fold = document.createElement('button'); + fold.type = 'button'; fold.tabIndex = -1; + fold.dataset.mapMarker = 'collapse'; fold.className = 'pm-node-marker'; + fold.textContent = this.view.collapsed.includes(id) ? '⊕' : '⊖'; + fold.setAttribute('aria-label', `${this.view.collapsed.includes(id) ? 'Expand' : 'Collapse'} ${item.title}`); + topic.append(fold); + } + const control = topic.parentElement?.querySelector('me-epd'); + if (control) { + control.setAttribute('role', 'button'); + control.setAttribute('aria-label', `${this.view.collapsed.includes(id) ? 'Expand' : 'Collapse'} ${item?.title ?? 'branch'}`); + control.setAttribute('aria-expanded', String(!this.view.collapsed.includes(id))); + } + } + } + + select(id: string | null, center = false): void { + if (!id) return; + try { + const topic = this.mind.findEle(id); + const wasRendering = this.rendering; + this.rendering = true; + this.mind.selectNode(topic); + this.rendering = wasRendering; + this.view.selected = id; + this.mind.container.setAttribute('aria-activedescendant', topic.id); + this.host.querySelectorAll('[aria-selected="true"]').forEach((entry) => entry.setAttribute('aria-selected', 'false')); + topic.setAttribute('aria-selected', 'true'); + if (center) this.centerReadableNode(topic); + } catch { /* A collapsed ancestor can hide a previously selected node. */ } + } + + private centerReadableNode(topic: Topic): void { + // Fit may use a tiny scale for a large map. A revealed label must be readable, + // and repeated search/navigation must not be skipped by the library's smooth-pan guard. + this.mind.map.style.transition = 'none'; + if (this.mind.scaleVal < 1) this.mind.scale(1); + const canvas = this.mind.container.getBoundingClientRect(); + const node = topic.getBoundingClientRect(); + this.mind.move( + canvas.left + canvas.width / 2 - node.left - node.width / 2, + canvas.top + canvas.height / 2 - node.top - node.height / 2, + false, + ); + this.saveViewport(); + } + + async beginEdit(id: string, text?: string): Promise { + if (this.editing || !this.canEdit(id)) return; + this.editText = text; + this.select(id, true); + await this.mind.beginEdit(this.mind.findEle(id)); + } + + private attachInlineEditor(id: string): void { + const editor = this.host.querySelector('#input-box'); + if (!editor) return; + this.editingId = id; + if (this.editText !== undefined) editor.textContent = this.editText; + this.editText = undefined; + editor.setAttribute('role', 'textbox'); + editor.setAttribute('aria-label', 'Direction title'); + editor.setAttribute('aria-multiline', 'false'); + this.callbacks.editStart(id, editor.textContent ?? ''); + let canceled = false; + editor.addEventListener('input', () => this.callbacks.editInput(editor.textContent ?? '')); + editor.addEventListener('keydown', (event) => { + if (event.isComposing) return; + if (event.key === 'Escape') canceled = true; + // Node titles are single lines. Native Enter/Tab ends editing; IME Enter is untouched. + if (event.key === 'Enter' && event.shiftKey) { event.preventDefault(); event.stopImmediatePropagation(); editor.blur(); } + }, { capture: true }); + editor.addEventListener('blur', () => { + const text = editor.innerText?.trim() ?? editor.textContent?.trim() ?? ''; + queueMicrotask(() => { + this.editingId = null; + this.callbacks.editEnd(id, text, canceled); + if (this.renderDeferred) { this.renderDeferred = false; this.render(this.snapshot, this.view); } + }); + }, { once: true }); + queueMicrotask(() => { + editor.focus(); + const range = document.createRange(); + range.selectNodeContents(editor); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + }); + } + + highlight(ids: Set): void { + this.host.querySelectorAll('me-tpc').forEach((topic) => topic.classList.toggle('pm-search-hit', ids.has(topic.nodeObj.id))); + } + + focus(): void { this.mind.container.focus({ preventScroll: true }); } + fit(): void { this.mind.scaleFit(); this.saveViewport(); } + zoom(delta: number): void { this.mind.scale(Math.max(0.1, Math.min(2, this.mind.scaleVal + delta))); } + + private getViewport(): NonNullable { + const transform = new DOMMatrixReadOnly(getComputedStyle(this.mind.map).transform); + return { x: transform.m41, y: transform.m42, scale: this.mind.scaleVal }; + } + private restoreViewport(viewport?: ViewState['viewport']): void { + if (!viewport) { this.mind.scaleFit(); return; } + this.mind.scaleVal = viewport.scale; + this.mind.map.style.transformOrigin = '50% 50%'; + this.mind.map.style.transform = `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.scale})`; + } + private saveViewport(): void { + clearTimeout(this.viewportTimer); + this.viewportTimer = setTimeout(() => this.callbacks.viewport(this.getViewport()), 150); + } + destroy(): void { + clearTimeout(this.viewportTimer); + this.abort.abort(); + this.mind.destroy(); + } +} diff --git a/rightmemory/web/frontend/src/tree.ts b/rightmemory/web/frontend/src/tree.ts new file mode 100644 index 0000000..be7e73d --- /dev/null +++ b/rightmemory/web/frontend/src/tree.ts @@ -0,0 +1,164 @@ +/** The editor's tree is deliberately independent of the canvas library. */ +export interface PursuitItem { + id: string; + title: string; + body: string; + parent_id: string | null; + child_ids: string[]; + edges: [string, string][]; + focused: boolean; + editable: boolean; + source_path?: string; + source_line?: number; + anchor_kind?: string; +} + +export interface Snapshot { + items: PursuitItem[]; + root_ids: string[]; + focus_ids: string[]; + revision: string; + git_head: string; + root_key: string; + valid: boolean; + writable: boolean; + diagnostics: (string | { message?: string; [key: string]: unknown })[]; +} + +export type Operation = + | { type: 'create'; parent_id: string | null; after_id: string | null; title: string } + | { type: 'rename'; id: string; title: string } + | { type: 'move'; id: string; parent_id: string | null; after_id: string | null } + | { type: 'delete'; id: string } + | { type: 'edit_body'; id: string; body: string } + | { type: 'set_focus'; id: string; focused: boolean }; + +export interface MutationResult { + snapshot: Snapshot; + commit: string | null; + operation_id: string; + repaired_references: unknown[]; + undoable: boolean; + selected_id: string | null; +} + +export const VIRTUAL_ROOT = '__pursuit_virtual_root__'; +export const DRAFT_PREFIX = '__pursuit_draft_'; + +export function indexTree(snapshot: Snapshot): Map { + return new Map(snapshot.items.map((item) => [item.id, item])); +} + +export function visualRoot(snapshot: Snapshot): string { + return snapshot.root_ids.length === 1 ? snapshot.root_ids[0] : VIRTUAL_ROOT; +} + +export function childrenOf(snapshot: Snapshot, parent: string | null): string[] { + return parent === null || parent === VIRTUAL_ROOT + ? snapshot.root_ids + : indexTree(snapshot).get(parent)?.child_ids ?? []; +} + +export function descendants(snapshot: Snapshot, id: string): Set { + const items = indexTree(snapshot); + const found = new Set(); + const todo = [id]; + while (todo.length) { + const next = todo.pop()!; + if (found.has(next)) continue; + found.add(next); + todo.push(...(items.get(next)?.child_ids ?? [])); + } + return found; +} + +export function assertMove(snapshot: Snapshot, id: string, parent: string | null, after: string | null): void { + const items = indexTree(snapshot); + if (!items.get(id)?.editable) throw new Error('This item cannot be moved.'); + if (parent && !items.get(parent)?.editable) throw new Error('Choose an editable destination.'); + if (parent && descendants(snapshot, id).has(parent)) throw new Error('A direction cannot be moved inside itself.'); + if (after === id) throw new Error('Choose a different insertion position.'); + if (after && !childrenOf(snapshot, parent).includes(after)) throw new Error('The insertion point has changed.'); +} + +/** Null after_id means the first position; callers pass the last sibling to append. */ +export function applyOperation(snapshot: Snapshot, operation: Operation, temporaryId?: string): Snapshot { + const next: Snapshot = { + ...snapshot, + items: snapshot.items.map((item) => ({ ...item, child_ids: [...item.child_ids], edges: item.edges.map((edge) => [...edge]) })), + root_ids: [...snapshot.root_ids], + focus_ids: [...snapshot.focus_ids], + }; + const items = indexTree(next); + const siblings = (parent: string | null) => parent === null ? next.root_ids : items.get(parent)!.child_ids; + const insert = (id: string, parent: string | null, after: string | null) => { + const list = siblings(parent); + if (after && !list.includes(after)) throw new Error('The insertion point has changed.'); + list.splice(after ? list.indexOf(after) + 1 : 0, 0, id); + }; + if (operation.type === 'create') { + if (!temporaryId) throw new Error('A local identity is needed while saving a new direction.'); + if (operation.parent_id && !items.get(operation.parent_id)?.editable) throw new Error('Choose an editable parent.'); + const item: PursuitItem = { + id: temporaryId, title: operation.title, body: '', parent_id: operation.parent_id, + child_ids: [], edges: [], focused: false, editable: true, + }; + next.items.push(item); + items.set(item.id, item); + insert(item.id, item.parent_id, operation.after_id); + return next; + } + const item = items.get(operation.id); + if (!item?.editable) throw new Error('This item is no longer editable. Reload the map.'); + switch (operation.type) { + case 'rename': item.title = operation.title; break; + case 'edit_body': item.body = operation.body; break; + case 'set_focus': + item.focused = operation.focused; + next.focus_ids = next.focus_ids.filter((id) => id !== item.id); + if (item.focused) next.focus_ids.push(item.id); + break; + case 'move': { + assertMove(snapshot, item.id, operation.parent_id, operation.after_id); + const old = siblings(item.parent_id); + old.splice(old.indexOf(item.id), 1); + item.parent_id = operation.parent_id; + insert(item.id, item.parent_id, operation.after_id); + break; + } + case 'delete': { + const removed = descendants(next, item.id); + const old = siblings(item.parent_id); + old.splice(old.indexOf(item.id), 1); + next.items = next.items.filter((candidate) => !removed.has(candidate.id)); + next.focus_ids = next.focus_ids.filter((id) => !removed.has(id)); + next.items.forEach((candidate) => { candidate.edges = candidate.edges.filter((edge) => !removed.has(edge[1])); }); + break; + } + } + return next; +} + +export function remapOperation(operation: Operation, temporaryId: string, id: string): Operation { + const mapped = { ...operation }; + if ('id' in mapped && mapped.id === temporaryId) mapped.id = id; + if ('parent_id' in mapped && mapped.parent_id === temporaryId) mapped.parent_id = id; + if ('after_id' in mapped && mapped.after_id === temporaryId) mapped.after_id = id; + return mapped; +} + +export function promoteOperation(snapshot: Snapshot, id: string): Operation | null { + const items = indexTree(snapshot); + const item = items.get(id); + if (!item?.parent_id) return null; + const parent = items.get(item.parent_id)!; + return { type: 'move', id, parent_id: parent.parent_id, after_id: parent.id }; +} + +export function deletionSelection(snapshot: Snapshot, id: string): string | null { + const item = indexTree(snapshot).get(id); + if (!item) return null; + const siblings = childrenOf(snapshot, item.parent_id); + const position = siblings.indexOf(id); + return siblings[position + 1] ?? siblings[position - 1] ?? item.parent_id ?? VIRTUAL_ROOT; +} diff --git a/rightmemory/web/frontend/src/view-state.ts b/rightmemory/web/frontend/src/view-state.ts new file mode 100644 index 0000000..b38ba0f --- /dev/null +++ b/rightmemory/web/frontend/src/view-state.ts @@ -0,0 +1,116 @@ +import { indexTree, visualRoot, VIRTUAL_ROOT, type Snapshot } from './tree.ts'; + +export interface ViewState { + selected: string | null; + collapsed: string[]; + viewport?: { x: number; y: number; scale: number }; +} + +export function visibleNodes(snapshot: Snapshot, collapsed: Iterable): string[] { + const folded = new Set(collapsed); + const items = indexTree(snapshot); + const result: string[] = []; + const visit = (id: string) => { + if (!items.has(id)) return; + result.push(id); + if (!folded.has(id)) items.get(id)!.child_ids.forEach(visit); + }; + if (visualRoot(snapshot) === VIRTUAL_ROOT) result.push(VIRTUAL_ROOT); + snapshot.root_ids.forEach(visit); + return result; +} + +export function reconcileView(snapshot: Snapshot, view: ViewState): ViewState { + const items = indexTree(snapshot); + const collapsed = view.collapsed.filter((id) => items.get(id)?.child_ids.length); + let selected = view.selected; + if (!selected || (selected !== VIRTUAL_ROOT && !items.has(selected))) selected = visualRoot(snapshot); + if (selected === VIRTUAL_ROOT && snapshot.root_ids.length === 1) selected = snapshot.root_ids[0]; + let parent = items.get(selected)?.parent_id; + while (parent) { + if (collapsed.includes(parent)) selected = parent; + parent = items.get(parent)?.parent_id; + } + return { ...view, selected, collapsed }; +} + +export function navigate(snapshot: Snapshot, view: ViewState, key: string): ViewState { + const state = reconcileView(snapshot, view); + const selected = state.selected!; + const item = indexTree(snapshot).get(selected); + const collapsed = new Set(state.collapsed); + const visible = visibleNodes(snapshot, collapsed); + if (key === 'ArrowDown' || key === 'ArrowUp') { + const offset = key === 'ArrowDown' ? 1 : -1; + state.selected = visible[Math.max(0, Math.min(visible.length - 1, visible.indexOf(selected) + offset))] ?? selected; + } else if (key === 'ArrowRight') { + if (selected === VIRTUAL_ROOT) state.selected = snapshot.root_ids[0] ?? VIRTUAL_ROOT; + else if (collapsed.has(selected)) collapsed.delete(selected); + else state.selected = item?.child_ids[0] ?? selected; + } else if (key === 'ArrowLeft') { + if (item?.child_ids.length && !collapsed.has(selected)) collapsed.add(selected); + else state.selected = item?.parent_id ?? visualRoot(snapshot); + } else if (key === 'Home') state.selected = visualRoot(snapshot); + else if (key === 'End') state.selected = visible.at(-1) ?? selected; + return { ...state, collapsed: [...collapsed] }; +} + +export type Command = 'rename' | 'sibling' | 'child' | 'promote' | 'delete' | 'collapse' | 'search' | + 'fit' | 'undo' | 'redo' | 'note' | 'focus' | 'navigate' | 'escape' | 'save'; + +export function keyboardCommand(event: Pick, editing = false): Command | null { + if (event.isComposing) return null; + const modified = event.ctrlKey || event.metaKey; + const key = event.key.toLowerCase(); + if (editing) { + if (modified && key === 's') return 'save'; + if (key === 'escape') return 'escape'; + return null; + } + if (modified) { + if (key === 'z') return event.shiftKey ? 'redo' : 'undo'; + if (key === 'y') return 'redo'; + if (key === 'f') return 'search'; + if (key === '0') return 'fit'; + return null; + } + if (event.altKey) return null; + if (event.key.startsWith('Arrow') || key === 'home' || key === 'end') return 'navigate'; + if (key === 'f2') return 'rename'; + if (key === 'enter') return event.shiftKey ? 'note' : 'sibling'; + if (key === 'tab') return event.shiftKey ? 'promote' : 'child'; + if (key === 'delete' || key === 'backspace') return 'delete'; + if (key === ' ') return 'collapse'; + if (key === 'n') return 'note'; + if (key === 'f') return 'focus'; + if (key === 'escape') return 'escape'; + return null; +} + +export function reveal(snapshot: Snapshot, view: ViewState, id: string): ViewState { + const items = indexTree(snapshot); + const folded = new Set(view.collapsed); + let parent = items.get(id)?.parent_id; + while (parent) { + folded.delete(parent); + parent = items.get(parent)?.parent_id; + } + return { ...view, selected: id, collapsed: [...folded] }; +} + +export function readView(storage: Pick, rootKey: string): ViewState { + try { + const saved = JSON.parse(storage.getItem(`rightmemory:pursuit-map:${rootKey}`) ?? 'null'); + const viewport = saved?.viewport; + return { + selected: typeof saved?.selected === 'string' ? saved.selected : null, + collapsed: Array.isArray(saved?.collapsed) ? saved.collapsed.filter((id: unknown) => typeof id === 'string') : [], + ...(viewport && [viewport.x, viewport.y, viewport.scale].every(Number.isFinite) && viewport.scale >= 0.1 && viewport.scale <= 2 + ? { viewport } : {}), + }; + } catch { return { selected: null, collapsed: [] }; } +} + +export function writeView(storage: Pick, rootKey: string, view: ViewState): void { + try { storage.setItem(`rightmemory:pursuit-map:${rootKey}`, JSON.stringify(view)); } catch { /* Private browsing may disable storage. */ } +} diff --git a/rightmemory/web/frontend/tests/browser.html b/rightmemory/web/frontend/tests/browser.html new file mode 100644 index 0000000..917b0c0 --- /dev/null +++ b/rightmemory/web/frontend/tests/browser.html @@ -0,0 +1,13 @@ + + + + + + Pursuit Map browser fixture + + + +
+ + + diff --git a/rightmemory/web/frontend/tests/browser.ts b/rightmemory/web/frontend/tests/browser.ts new file mode 100644 index 0000000..12d1f61 --- /dev/null +++ b/rightmemory/web/frontend/tests/browser.ts @@ -0,0 +1,49 @@ +import { mountMap } from '../src/pursuit-map.ts'; +import { ApiError, type Transport } from '../src/queue.ts'; +import { applyOperation, type MutationResult, type Snapshot } from '../src/tree.ts'; +import { fixture } from './fixtures.ts'; + +// Disposable browser fixture. It never contacts a Memory root or persists semantic data. +const requested = Number(new URL(location.href).searchParams.get('nodes') ?? 22); +let current = fixture(Math.min(1000, Math.max(22, requested))); +let serial = 0; +let failNext = false; +let slow = false; +const commits = new Map(); +const clone = (value: T): T => structuredClone(value); +const latency = () => new Promise((resolve) => setTimeout(resolve, slow ? 1800 : 100)); +const result = (before: Snapshot, next: Snapshot, selected_id: string | null): MutationResult => { + serial++; + current = { ...next, revision: `r${serial}`, git_head: `c${serial}` }; + commits.set(current.git_head, { before: clone(before), after: clone(current) }); + return { snapshot: clone(current), commit: current.git_head, operation_id: `operation-${serial}`, repaired_references: [], undoable: true, selected_id }; +}; +const transport: Transport = { + load: async () => clone(current), + mutate: async (revision, operation) => { + await latency(); + if (failNext) { + failNext = false; + current = { ...current, revision: `external-${++serial}` }; + throw new ApiError('The map changed in another window.', 409, clone(current)); + } + if (revision !== current.revision) throw new ApiError('The map changed in another window.', 409, clone(current)); + const id = operation.type === 'create' ? `created-${serial + 1}` : operation.id; + return result(current, applyOperation(current, operation, id), operation.type === 'delete' ? null : id); + }, + history: async (_kind, revision, commit) => { + await latency(); + const entry = commits.get(commit); + if (!entry || revision !== current.revision) throw new ApiError('History changed elsewhere.', 409, clone(current)); + return result(current, entry.before, entry.before.root_ids[0] ?? null); + }, +}; +const host = document.querySelector('#fixture')!; +host.style.height = 'calc(100vh - 40px)'; +await mountMap(host, transport); +const tools = document.createElement('div'); +tools.style.cssText = 'height:40px;padding:6px 12px;box-sizing:border-box;display:flex;gap:14px;align-items:center;background:#edf0e9;font:12px system-ui;'; +tools.innerHTML = 'Disposable fixture500 directionsNo real Memory data is used.'; +tools.querySelector('input')!.addEventListener('change', (event) => { slow = (event.target as HTMLInputElement).checked; }); +tools.querySelector('button')!.addEventListener('click', () => { failNext = true; }); +document.body.append(tools); diff --git a/rightmemory/web/frontend/tests/fixtures.ts b/rightmemory/web/frontend/tests/fixtures.ts new file mode 100644 index 0000000..b9c7ba3 --- /dev/null +++ b/rightmemory/web/frontend/tests/fixtures.ts @@ -0,0 +1,31 @@ +import { type PursuitItem, type Snapshot } from '../src/tree.ts'; + +export function fixture(count = 22): Snapshot { + const items: PursuitItem[] = []; + const add = (id: string, title: string, parent: string | null) => { + const item: PursuitItem = { id, title, parent_id: parent, child_ids: [], body: '', edges: [], focused: false, editable: true }; + items.push(item); + if (parent) items.find((candidate) => candidate.id === parent)!.child_ids.push(id); + return item; + }; + add('directions', 'Directions 方向', null); + add('research', 'Research 研究', 'directions'); + add('design', 'Design 设计', 'directions'); + add('writing', 'Writing 写作', 'directions'); + let parent = 'research'; + for (let depth = 1; depth <= 7; depth++) { + const child = add(`level-${depth}`, `Explore ${depth} 探索`, parent); + add(`alternative-${depth}`, `Alternative ${depth}`, parent); + parent = child.id; + } + add('interaction', 'Interaction study', 'design').body = 'Observe how a new reader understands the map.\n\nKeep the labels short.'; + add('visual', 'Visual language', 'design'); + add('essays', 'Essays 随笔', 'writing').focused = true; + add('drafts', 'Ideas', 'writing'); + while (items.length < count) { + const index = items.length; + const parentId = index % 6 === 0 ? 'directions' : items[Math.max(1, Math.floor(index / 5))].id; + add(`generated-${index}`, `Direction ${index} 方向`, parentId); + } + return { items, root_ids: ['directions'], focus_ids: ['essays'], revision: 'r0', git_head: 'c0', root_key: 'browser-fixture', valid: true, writable: true, diagnostics: [] }; +} diff --git a/rightmemory/web/frontend/tests/queue.test.ts b/rightmemory/web/frontend/tests/queue.test.ts new file mode 100644 index 0000000..6304522 --- /dev/null +++ b/rightmemory/web/frontend/tests/queue.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ApiError, MutationQueue, type Transport } from '../src/queue.ts'; +import { applyOperation, indexTree, type MutationResult, type Operation } from '../src/tree.ts'; +import { fixture } from './fixtures.ts'; + +function harness() { + let current = fixture(); + const requests: Array<{ revision: string; operation: Operation; resolve(value: MutationResult): void; reject(error: Error): void }> = []; + const history: Array<{ kind: string; revision: string; commit: string }> = []; + const transport: Transport = { + load: async () => current, + mutate: (revision, operation) => new Promise((resolve, reject) => requests.push({ revision, operation, resolve, reject })), + history: async (kind, revision, commit) => { + history.push({ kind, revision, commit }); + const landed = `history-${history.length}`; + current = { ...current, revision: landed, git_head: landed }; + return { snapshot: current, commit: landed, operation_id: landed, repaired_references: [], undoable: true, selected_id: null }; + }, + }; + const respond = (index: number, id?: string) => { + const request = requests[index]; + const next = applyOperation(current, request.operation, id); + current = { ...next, revision: `r${index + 1}`, git_head: `c${index + 1}` }; + request.resolve({ snapshot: current, commit: current.git_head, operation_id: `o${index}`, repaired_references: [], undoable: true, selected_id: id ?? ('id' in request.operation ? request.operation.id : null) }); + }; + return { queue: new MutationQueue(current, transport), requests, history, respond, get current() { return current; } }; +} + +test('optimistic edits are immediate and server requests run in order with the returned revision', async () => { + const { queue, requests, respond } = harness(); + const first = queue.enqueue({ type: 'rename', id: 'design', title: 'New title' }); + const second = queue.enqueue({ type: 'set_focus', id: 'design', focused: true }); + assert.equal(requests.length, 1); + assert.equal(indexTree(queue.snapshot).get('design')!.title, 'New title'); + assert.equal(indexTree(queue.snapshot).get('design')!.focused, true); + respond(0); await first; + assert.equal(requests.length, 2); + assert.equal(requests[1].revision, 'r1'); + assert.equal(indexTree(queue.snapshot).get('design')!.focused, true); + respond(1); await second; + assert.equal(queue.pendingCount, 0); + assert.equal(queue.snapshot.revision, 'r2'); +}); + +test('rapid parent then child creation remaps pending parent, rename, move and sibling references', async () => { + const { queue, requests, respond } = harness(); + const parent = queue.enqueue({ type: 'create', parent_id: 'design', after_id: null, title: 'Parent 父' }, 'temp-parent'); + const child = queue.enqueue({ type: 'create', parent_id: 'temp-parent', after_id: null, title: 'Child 子' }, 'temp-child'); + const rename = queue.enqueue({ type: 'rename', id: 'temp-child', title: 'Renamed 子方向' }); + const move = queue.enqueue({ type: 'move', id: 'visual', parent_id: 'temp-parent', after_id: 'temp-child' }); + const remaps: unknown[] = []; + queue.subscribe((change) => { if (change.remapped) remaps.push(change.remapped); }); + respond(0, 'saved-parent'); await parent; + assert.deepEqual(requests[1].operation, { type: 'create', parent_id: 'saved-parent', after_id: null, title: 'Child 子' }); + respond(1, 'saved-child'); await child; + assert.deepEqual(requests[2].operation, { type: 'rename', id: 'saved-child', title: 'Renamed 子方向' }); + respond(2); await rename; + assert.deepEqual(requests[3].operation, { type: 'move', id: 'visual', parent_id: 'saved-parent', after_id: 'saved-child' }); + respond(3); await move; + assert.deepEqual(indexTree(queue.snapshot).get('saved-parent')!.child_ids, ['saved-child', 'visual']); + assert.deepEqual(remaps, [{ from: 'temp-parent', to: 'saved-parent' }, { from: 'temp-child', to: 'saved-child' }]); +}); + +test('a revision conflict rolls back queued changes to the server snapshot without replaying them', async () => { + const { queue, requests } = harness(); + const first = queue.enqueue({ type: 'rename', id: 'design', title: 'Unsaved title' }); + const second = queue.enqueue({ type: 'edit_body', id: 'design', body: 'Unsaved note' }); + const settled = Promise.allSettled([first, second]); + const external = { ...applyOperation(fixture(), { type: 'rename', id: 'design', title: 'External title' }), revision: 'external' }; + requests[0].reject(new ApiError('Conflict', 409, external)); + const results = await settled; + assert(results.every((result) => result.status === 'rejected')); + assert.equal(requests.length, 1); + assert.equal(indexTree(queue.snapshot).get('design')!.title, 'External title'); + assert.equal(indexTree(queue.snapshot).get('design')!.body, ''); + assert.equal(queue.pendingCount, 0); + assert.equal(queue.canUndo, false); +}); + +test('undo uses landed commits and redo uses the returned revert commit', async () => { + const { queue, respond, history } = harness(); + const edit = queue.enqueue({ type: 'rename', id: 'design', title: 'Edited' }); + assert.equal(queue.canUndo, false); + respond(0); await edit; + await queue.history('undo'); + assert.deepEqual(history[0], { kind: 'undo', revision: 'r1', commit: 'c1' }); + assert.equal(queue.canRedo, true); + await queue.history('redo'); + assert.deepEqual(history[1], { kind: 'redo', revision: 'history-1', commit: 'history-1' }); + await queue.history('undo'); + assert.equal(history[2].commit, 'history-2'); +}); + +test('a no-op response creates no undo entry', async () => { + const { queue, requests, current } = harness(); + const edit = queue.enqueue({ type: 'rename', id: 'design', title: 'Design 设计' }); + requests[0].resolve({ snapshot: current, commit: null, operation_id: 'noop', repaired_references: [], undoable: false, selected_id: 'design' }); + await edit; + assert.equal(queue.canUndo, false); +}); + +test('authoritative plain-heading ID promotion remaps queued references', async () => { + const { queue, requests, current } = harness(); + const edit = queue.enqueue({ type: 'rename', id: 'design', title: 'Addressable' }); + const note = queue.enqueue({ type: 'edit_body', id: 'design', body: 'Still my note' }); + const canonical = structuredClone(current); + canonical.items.forEach((item) => { + if (item.id === 'design') item.id = 'design-stable'; + if (item.parent_id === 'design') item.parent_id = 'design-stable'; + item.child_ids = item.child_ids.map((id) => id === 'design' ? 'design-stable' : id); + }); + requests[0].resolve({ snapshot: canonical, commit: 'promoted', operation_id: 'promoted', repaired_references: [], undoable: true, selected_id: 'design-stable' }); + await edit; + assert.deepEqual(requests[1].operation, { type: 'edit_body', id: 'design-stable', body: 'Still my note' }); + requests[1].resolve({ snapshot: canonical, commit: 'note', operation_id: 'note', repaired_references: [], undoable: true, selected_id: 'design-stable' }); + await note; +}); diff --git a/rightmemory/web/frontend/tests/tree.test.ts b/rightmemory/web/frontend/tests/tree.test.ts new file mode 100644 index 0000000..a65a441 --- /dev/null +++ b/rightmemory/web/frontend/tests/tree.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyOperation, assertMove, descendants, indexTree, promoteOperation, visualRoot, VIRTUAL_ROOT } from '../src/tree.ts'; +import { fixture } from './fixtures.ts'; + +test('create inserts at the requested sibling position without mutating the source tree', () => { + const original = fixture(); + const before = structuredClone(original); + const created = applyOperation(original, { type: 'create', parent_id: 'design', after_id: 'interaction', title: 'Prototype 原型' }, 'temporary'); + assert.deepEqual(indexTree(created).get('design')!.child_ids, ['interaction', 'temporary', 'visual']); + assert.equal(indexTree(created).get('temporary')!.title, 'Prototype 原型'); + assert.deepEqual(original, before); +}); + +test('rename, note, and Focus preserve identity and subtree order', () => { + const original = fixture(); + const renamed = applyOperation(original, { type: 'rename', id: 'design', title: 'Design 设计 plain text' }); + const noted = applyOperation(renamed, { type: 'edit_body', id: 'design', body: '# Notes\n\n- free form\n' }); + const focused = applyOperation(noted, { type: 'set_focus', id: 'design', focused: true }); + assert.equal(indexTree(focused).get('design')!.id, 'design'); + assert.deepEqual(indexTree(focused).get('design')!.child_ids, indexTree(original).get('design')!.child_ids); + assert.equal(indexTree(focused).get('design')!.body, '# Notes\n\n- free form\n'); + assert.deepEqual(focused.focus_ids, ['essays', 'design']); +}); + +test('move and promote keep a deep subtree intact while changing sibling order', () => { + const original = fixture(); + const subtree = descendants(original, 'level-1'); + const moved = applyOperation(original, { type: 'move', id: 'level-1', parent_id: 'writing', after_id: null }); + assert.equal(indexTree(moved).get('level-1')!.parent_id, 'writing'); + assert.deepEqual(descendants(moved, 'level-1'), subtree); + assert.equal(indexTree(moved).get('writing')!.child_ids[0], 'level-1'); + const promoted = applyOperation(moved, promoteOperation(moved, 'level-1')!); + assert.deepEqual(indexTree(promoted).get('directions')!.child_ids, ['research', 'design', 'writing', 'level-1']); +}); + +test('cycle and missing destination rejection leave source unchanged', () => { + const original = fixture(); + assert.throws(() => assertMove(original, 'research', 'level-7', null), /inside itself/); + assert.throws(() => applyOperation(original, { type: 'move', id: 'research', parent_id: 'missing', after_id: null }), /destination/); + assert.equal(indexTree(original).get('research')!.parent_id, 'directions'); +}); + +test('delete removes logical descendants, Focus, and inbound edges; a real visual root can be removed', () => { + const original = fixture(); + indexTree(original).get('research')!.edges = [['ref', 'essays'], ['ref', 'interaction']]; + const removed = applyOperation(original, { type: 'delete', id: 'writing' }); + assert(!indexTree(removed).has('essays')); + assert.deepEqual(removed.focus_ids, []); + assert.deepEqual(indexTree(removed).get('research')!.edges, [['ref', 'interaction']]); + const empty = applyOperation(removed, { type: 'delete', id: 'directions' }); + assert.equal(visualRoot(empty), VIRTUAL_ROOT); + assert.deepEqual(empty.items, []); +}); + +test('a second top-level item changes the visual root without inventing a semantic node', () => { + const original = fixture(); + const plural = applyOperation(original, { type: 'create', parent_id: null, after_id: 'directions', title: 'Another direction' }, 'second'); + assert.equal(visualRoot(original), 'directions'); + assert.equal(visualRoot(plural), VIRTUAL_ROOT); + assert.equal(plural.items.length, original.items.length + 1); +}); + +test('500-item edits preserve every unrelated item', () => { + const original = fixture(500); + const next = applyOperation(original, { type: 'rename', id: 'generated-499', title: '五百 / Five hundred' }); + assert.equal(next.items.length, 500); + assert.deepEqual(next.items.slice(0, 499), original.items.slice(0, 499)); +}); diff --git a/rightmemory/web/frontend/tests/view-state.test.ts b/rightmemory/web/frontend/tests/view-state.test.ts new file mode 100644 index 0000000..f52b95d --- /dev/null +++ b/rightmemory/web/frontend/tests/view-state.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { navigate, keyboardCommand, readView, reconcileView, reveal, visibleNodes, writeView, type ViewState } from '../src/view-state.ts'; +import { applyOperation } from '../src/tree.ts'; +import { DraftBook, type TitleDraft } from '../src/drafts.ts'; +import { fixture } from './fixtures.ts'; + +const key = (key: string, changes: Partial = {}) => ({ key, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, isComposing: false, ...changes }); + +test('keyboard mapping covers direct editing and respects input composition', () => { + assert.equal(keyboardCommand(key('Enter')), 'sibling'); + assert.equal(keyboardCommand(key('Tab')), 'child'); + assert.equal(keyboardCommand(key('Tab', { shiftKey: true })), 'promote'); + assert.equal(keyboardCommand(key('z', { ctrlKey: true, shiftKey: true })), 'redo'); + assert.equal(keyboardCommand(key('F2')), 'rename'); + assert.equal(keyboardCommand(key('Enter', { isComposing: true })), null); + assert.equal(keyboardCommand(key('Backspace'), true), null); + assert.equal(keyboardCommand(key('z', { metaKey: true }), true), null); + assert.equal(keyboardCommand(key('s', { metaKey: true }), true), 'save'); +}); + +test('arrow navigation folds branches and selects visible neighbors', () => { + const snapshot = fixture(); + let view: ViewState = { selected: 'research', collapsed: [] }; + view = navigate(snapshot, view, 'ArrowLeft'); + assert.deepEqual(view.collapsed, ['research']); + assert(!visibleNodes(snapshot, view.collapsed).includes('level-7')); + view = navigate(snapshot, view, 'ArrowDown'); + assert.equal(view.selected, 'design'); + view = navigate(snapshot, view, 'ArrowRight'); + assert.equal(view.selected, 'interaction'); + view = navigate(snapshot, view, 'ArrowLeft'); + assert.equal(view.selected, 'design'); +}); + +test('selection reconciliation never leaves a hidden or deleted node selected', () => { + const snapshot = fixture(); + const view = reconcileView(snapshot, { selected: 'level-7', collapsed: ['research', 'missing'] }); + assert.equal(view.selected, 'research'); + assert.deepEqual(view.collapsed, ['research']); + const removed = applyOperation(snapshot, { type: 'delete', id: 'research' }); + assert.equal(reconcileView(removed, view).selected, 'directions'); + assert.deepEqual(reveal(snapshot, view, 'level-7').collapsed, []); +}); + +test('browser view state is isolated by root', () => { + const values = new Map(); + const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); } }; + const view = { selected: 'research', collapsed: ['design'], viewport: { x: 10, y: 20, scale: 0.8 } }; + writeView(storage, 'root-a', view); + assert.deepEqual(readView(storage, 'root-a'), view); + assert.deepEqual(readView(storage, 'root-b'), { selected: null, collapsed: [] }); + storage.setItem('rightmemory:pursuit-map:broken', '{bad'); + assert.deepEqual(readView(storage, 'broken'), { selected: null, collapsed: [] }); +}); + +test('note conflict keeps unsaved text even when an authoritative snapshot changes its body', () => { + const book = new DraftBook(); + const note = book.openNote('design', 'Original'); + note.text = 'My unsaved 中文 note'; + note.saving = true; + book.noteFailed('design', 'Conflict'); + const external = applyOperation(fixture(), { type: 'edit_body', id: 'design', body: 'Another writer' }); + book.reconcile(external); + assert.equal(book.note!.text, 'My unsaved 中文 note'); + assert.equal(book.note!.savedText, 'Original'); + assert.equal(book.note!.error, 'Conflict'); +}); + +test('a note typed during saving stays dirty after the older request succeeds', () => { + const book = new DraftBook(); + const note = book.openNote('design', 'Original'); + note.text = 'Submitted'; note.saving = true; + note.text = 'Typed while saving'; + book.noteSaved('design', 'Submitted'); + assert.equal(book.note!.text, 'Typed while saving'); + assert.equal(book.note!.savedText, 'Submitted'); + assert(book.dirty); +}); + +test('failed creation retains its title and remaps a just-saved parent', () => { + const book = new DraftBook(); + const draft: TitleDraft = { id: 'temp-child', temporaryId: 'temp-child', text: 'Child 子方向', operation: { type: 'create', title: 'Child 子方向', parent_id: 'temp-parent', after_id: null } }; + book.savingTitles.push(draft); + book.remap('temp-parent', 'saved-parent'); + book.failedTitle(draft, 'Conflict'); + assert.equal(book.failedTitles[0].text, 'Child 子方向'); + assert.equal(book.failedTitles[0].operation.type === 'create' && book.failedTitles[0].operation.parent_id, 'saved-parent'); + assert(book.dirty); +}); diff --git a/rightmemory/web/frontend/tsconfig.json b/rightmemory/web/frontend/tsconfig.json new file mode 100644 index 0000000..065b276 --- /dev/null +++ b/rightmemory/web/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/rightmemory/web/process.py b/rightmemory/web/process.py index bb21b6c..eb387af 100644 --- a/rightmemory/web/process.py +++ b/rightmemory/web/process.py @@ -2,6 +2,8 @@ import json import os +import secrets +import signal import subprocess import sys import time @@ -21,7 +23,9 @@ MANAGED_WEB_ENV = "RIGHTMEMORY_MANAGED_WEB" +WEB_LAUNCH_ID_ENV = "RIGHTMEMORY_WEB_LAUNCH_ID" WEB_START_TIMEOUT_SECONDS = 10 +WEB_START_CLEANUP_TIMEOUT_SECONDS = 5 @dataclass(frozen=True) @@ -61,6 +65,10 @@ def web_ready_path(memory_root: Path) -> Path: return Path(memory_root) / WEB_RUNTIME_DIR / "web.ready" +def web_launch_path(memory_root: Path) -> Path: + return Path(memory_root) / WEB_RUNTIME_DIR / "web.launch-id" + + def web_settings_path(memory_root: Path) -> Path: return Path(memory_root) / WEB_RUNTIME_DIR / "settings.json" @@ -89,12 +97,15 @@ def start_web_service( raise ValueError("port must be between 0 and 65535") status = web_service_status(root) if status.state == "running": + if status.pid is not None: + _wait_for_web_ready(root, status.pid) return status if status.state == "stale": _clear_web_registration(root, status.pid) web_pid_path(root).unlink(missing_ok=True) web_identity_path(root).unlink(missing_ok=True) web_ready_path(root).unlink(missing_ok=True) + web_launch_path(root).unlink(missing_ok=True) web_stop_path(root).unlink(missing_ok=True) generated_operator_token = ensure_web_auth_files(root) runtime_dir = root / WEB_RUNTIME_DIR @@ -114,7 +125,8 @@ def start_web_service( "--port", str(port), ] - env = {**_web_child_env(root), MANAGED_WEB_ENV: "1"} + launch_id = secrets.token_hex(16) + env = {**_web_child_env(root), MANAGED_WEB_ENV: "1", WEB_LAUNCH_ID_ENV: launch_id} with log_path.open("ab") as log: process = subprocess.Popen( command, @@ -125,7 +137,7 @@ def start_web_service( env=env, **detached_process_kwargs(), ) - pid = _wait_for_web_registration(root, process) + pid = _wait_for_web_registration(root, process, launch_id=launch_id) return WebServiceStatus("running", pid, host, port, log_path, generated_operator_token) @@ -256,13 +268,20 @@ def _read_identity(path: Path) -> str | None: return value or None -def register_web_process(memory_root: Path, pid: int, *, ready: bool = False) -> None: +def register_web_process( + memory_root: Path, pid: int, *, ready: bool = False, launch_id: str | None = None, +) -> None: identity_path = web_identity_path(memory_root) identity = process_identity(pid) if identity is None: identity_path.unlink(missing_ok=True) else: _write_value(identity_path, identity) + launch_id = launch_id or os.environ.get(WEB_LAUNCH_ID_ENV) + if launch_id: + _write_value(web_launch_path(memory_root), launch_id) + else: + web_launch_path(memory_root).unlink(missing_ok=True) _write_pid(web_pid_path(memory_root), pid) if ready: _write_pid(web_ready_path(memory_root), pid) @@ -273,29 +292,124 @@ def _clear_web_registration(memory_root: Path, pid: int | None) -> None: return web_pid_path(memory_root).unlink(missing_ok=True) web_identity_path(memory_root).unlink(missing_ok=True) + web_launch_path(memory_root).unlink(missing_ok=True) def _wait_for_web_registration( memory_root: Path, process: subprocess.Popen[bytes], timeout_seconds: float = WEB_START_TIMEOUT_SECONDS, + *, + launch_id: str | None = None, ) -> int: if not callable(getattr(process, "poll", None)): - register_web_process(memory_root, process.pid, ready=True) + register_web_process(memory_root, process.pid, ready=True, launch_id=launch_id) return process.pid + return _wait_for_web_ready( + memory_root, process.pid, timeout_seconds=timeout_seconds, process=process, launch_id=launch_id, + ) + + +def _wait_for_web_ready( + memory_root: Path, + expected_pid: int, + *, + timeout_seconds: float = WEB_START_TIMEOUT_SECONDS, + process: subprocess.Popen[bytes] | None = None, + launch_id: str | None = None, +) -> int: deadline = time.monotonic() + timeout_seconds while time.monotonic() <= deadline: + if process is not None: + return_code = process.poll() + if return_code is not None: + _cleanup_failed_web_start(memory_root, process, launch_id) + raise RuntimeError( + f"rightmemory web service exited with code {return_code}; see {web_log_path(memory_root)}" + ) + elif not _is_web_process(expected_pid, memory_root=memory_root): + clear_web_process_files(memory_root, expected_pid) + raise RuntimeError(f"rightmemory web service stopped before it was ready; see {web_log_path(memory_root)}") pid = _read_pid(web_ready_path(memory_root)) identity = _read_identity(web_identity_path(memory_root)) - if pid is not None: + registered_pid = _read_pid(web_pid_path(memory_root)) + matches_launch = ( + _read_identity(web_launch_path(memory_root)) == launch_id + if launch_id is not None else pid == expected_pid + ) + if pid is not None and pid == registered_pid and matches_launch: if identity is not None and process_identity(pid) == identity: return pid if identity is None and _is_web_process(pid): return pid time.sleep(0.05) + if process is not None: + _cleanup_failed_web_start(memory_root, process, launch_id) + raise RuntimeError(f"rightmemory web service did not become ready within {timeout_seconds:g} seconds") + + +def _owned_launch_process(memory_root: Path, launch_id: str | None) -> tuple[int, str] | None: + if launch_id is None or _read_identity(web_launch_path(memory_root)) != launch_id: + return None + pid = _read_pid(web_pid_path(memory_root)) + identity = _read_identity(web_identity_path(memory_root)) + if pid is None or identity is None or process_identity(pid) != identity: + return None + return pid, identity + + +def _cleanup_failed_web_start( + memory_root: Path, process: subprocess.Popen[bytes], launch_id: str | None, +) -> None: + owned = _owned_launch_process(memory_root, launch_id) + try: + if owned is not None: + pid, identity = owned + _write_pid(web_stop_path(memory_root), pid) + if not _wait_for_exit(pid, WEB_START_CLEANUP_TIMEOUT_SECONDS, identity=identity): + _terminate_owned_web_process(pid, identity) + finally: + # On Windows a venv python.exe may be a redirector, not the registered + # server. If registration never happened, stop its owned process tree. + _terminate_web_launcher(process, include_children=owned is None) + if launch_id is None or _read_identity(web_launch_path(memory_root)) == launch_id: + registered_pid = _read_pid(web_pid_path(memory_root)) if launch_id is not None else process.pid + if registered_pid is not None: + clear_web_process_files(memory_root, registered_pid) + + +def _terminate_owned_web_process(pid: int, identity: str) -> None: + for sig in dict.fromkeys((signal.SIGTERM, getattr(signal, "SIGKILL", signal.SIGTERM))): + if process_identity(pid) != identity: + return + try: + os.kill(pid, sig) + except ProcessLookupError: + return + if _wait_for_exit(pid, WEB_START_CLEANUP_TIMEOUT_SECONDS, identity=identity): + return + raise RuntimeError(f"could not stop owned Web startup process {pid}") + + +def _terminate_web_launcher(process: subprocess.Popen[bytes], *, include_children: bool) -> None: if process.poll() is None: - process.terminate() - raise RuntimeError(f"rightmemory web service did not register within {timeout_seconds:g} seconds") + if include_children and os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + check=False, timeout=WEB_START_CLEANUP_TIMEOUT_SECONDS, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + if process.poll() is None: + try: + process.terminate() + except ProcessLookupError: + pass + try: + process.wait(timeout=WEB_START_CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=WEB_START_CLEANUP_TIMEOUT_SECONDS) def _unlink_if_pid(path: Path, pid: int) -> None: diff --git a/rightmemory/web/service.py b/rightmemory/web/service.py index 0bb1a9b..22c80a2 100644 --- a/rightmemory/web/service.py +++ b/rightmemory/web/service.py @@ -16,6 +16,7 @@ load_review_config, load_sync_config, ) +from ..pursuit_store import PursuitStore from ..session import MemoryWriteLock from ..share_models import ShareRelationship, load_shares from ..share_results import capability_from_parts @@ -92,6 +93,32 @@ def status(self) -> dict[str, Any]: status = collect_status(self.memory_root) return _json_safe(status) + def pursuit_map(self) -> dict[str, Any]: + return PursuitStore(self.memory_root).snapshot() + + def apply_pursuit_operation(self, payload: dict[str, Any], session_id: str) -> dict[str, Any]: + expected_revision = _required_payload_str(payload, "expected_revision") + operation = payload.get("operation") + if not isinstance(operation, dict): + raise ValueError("operation must be an object") + return PursuitStore(self.memory_root).apply( + operation, expected_revision=expected_revision, session_id=session_id, + ) + + def undo_pursuit_operation(self, payload: dict[str, Any], session_id: str) -> dict[str, Any]: + return PursuitStore(self.memory_root).undo( + _required_payload_str(payload, "commit"), + expected_revision=_required_payload_str(payload, "expected_revision"), + session_id=session_id, + ) + + def redo_pursuit_operation(self, payload: dict[str, Any], session_id: str) -> dict[str, Any]: + return PursuitStore(self.memory_root).redo( + _required_payload_str(payload, "commit"), + expected_revision=_required_payload_str(payload, "expected_revision"), + session_id=session_id, + ) + def settings(self) -> dict[str, Any]: config_path = self.memory_root / "rightmemory.toml" return { diff --git a/rightmemory/web/static/app.js b/rightmemory/web/static/app.js index 7189b48..baf5009 100644 --- a/rightmemory/web/static/app.js +++ b/rightmemory/web/static/app.js @@ -1,11 +1,13 @@ const state = { csrfToken: null, authenticated: false, - panel: "overview", + panel: location.hash === "#pursuit-map" ? "pursuit-map" : "overview", + pursuitMap: null, }; const titleByPanel = { overview: "Overview", + "pursuit-map": "Pursuit Map", shared: "Shared Views", memory: "Memory", insights: "Insights", @@ -26,7 +28,10 @@ async function fetchJson(path, options = {}) { const payload = await response.json(); if (!response.ok) { const detail = payload.detail || {}; - throw new Error(detail.message || payload.message || `Request failed: ${response.status}`); + const error = new Error(detail.message || payload.message || `Request failed: ${response.status}`); + error.status = response.status; + error.detail = detail; + throw error; } return payload; } @@ -1043,6 +1048,22 @@ function renderArtifactButton(item, type, endpoint) { async function loadPanel() { document.querySelector("#panel-title").textContent = titleByPanel[state.panel] || "Overview"; + const isPursuit = state.panel === "pursuit-map"; + document.body.classList.toggle("pursuit-view", isPursuit); + document.querySelector("#content").hidden = isPursuit; + document.querySelector("#pursuit-content").hidden = !isPursuit; + document.querySelectorAll(".nav-item").forEach((item) => item.classList.toggle("active", item.dataset.panel === state.panel)); + if (isPursuit) { + if (!state.pursuitMap) { + const { mountPursuitMap } = await import("/static/pursuit-map.js"); + state.pursuitMap = await mountPursuitMap(document.querySelector("#pursuit-content"), fetchJson); + } else { + state.pursuitMap.setActive(true); + await state.pursuitMap.refresh(); + } + return; + } + state.pursuitMap?.setActive(false); let html = ""; if (state.panel === "overview") { const payload = await fetchJson("/api/overview"); @@ -1116,6 +1137,7 @@ document.querySelectorAll(".nav-item").forEach((button) => { document.querySelectorAll(".nav-item").forEach((item) => item.classList.remove("active")); button.classList.add("active"); state.panel = button.dataset.panel; + history.replaceState(null, "", state.panel === "pursuit-map" ? "#pursuit-map" : `#${state.panel}`); try { await loadPanel(); } catch (error) { @@ -1124,6 +1146,14 @@ document.querySelectorAll(".nav-item").forEach((button) => { }); }); +window.addEventListener("hashchange", () => { + const panel = location.hash.slice(1); + if (titleByPanel[panel] && panel !== state.panel) { + state.panel = panel; + if (state.authenticated) loadPanel().catch((error) => setMessage(error.message)); + } +}); + function attachPanelHandlers() { attachSharedViewHandlers(); attachSettingsHandlers(); @@ -1589,12 +1619,18 @@ function attachSettingsHandlers() { event.preventDefault(); try { const root = new FormData(event.currentTarget).get("root"); + if (state.pursuitMap?.hasUnsavedChanges) { + setMessage("Save or discard unsaved Pursuit Map edits before changing the active root."); + return; + } const payload = await fetchJson("/api/active-root", { method: "POST", body: JSON.stringify({ root }), }); state.csrfToken = payload.data.csrf_token || state.csrfToken; document.querySelector("#active-root").textContent = payload.data.active_root || ""; + state.pursuitMap?.destroy(); + state.pursuitMap = null; setMessage(payload.message); await loadPanel(); } catch (error) { diff --git a/rightmemory/web/static/index.html b/rightmemory/web/static/index.html index d00d4fd..e91aab5 100644 --- a/rightmemory/web/static/index.html +++ b/rightmemory/web/static/index.html @@ -5,6 +5,7 @@ RightMemory Web Studio +
@@ -12,6 +13,7 @@
RightMemory
diff --git a/rightmemory/web/static/pursuit-map.LICENSE.txt b/rightmemory/web/static/pursuit-map.LICENSE.txt new file mode 100644 index 0000000..71af91e --- /dev/null +++ b/rightmemory/web/static/pursuit-map.LICENSE.txt @@ -0,0 +1,24 @@ +Mind Elixir 5.15.1 +https://github.com/SSShooter/mind-elixir-core + +MIT License + +Copyright (c) 2019 DjZhou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rightmemory/web/static/pursuit-map.css b/rightmemory/web/static/pursuit-map.css new file mode 100644 index 0000000..cd0a6c6 --- /dev/null +++ b/rightmemory/web/static/pursuit-map.css @@ -0,0 +1 @@ +.map-container p{margin:0}.map-container{-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-ms-user-select:none;user-select:none;touch-action:none;outline:none;width:100%;height:100%;font-family:-apple-system,BlinkMacSystemFont,Helvetica Neue,PingFang SC,Microsoft YaHei,Source Han Sans SC,Noto Sans CJK SC,WenQuanYi Micro Hei,sans-serif;font-size:16px;overflow:hidden}.map-container *{box-sizing:border-box}.map-container::-webkit-scrollbar{width:0;height:0}.map-container .selected{outline:2px solid var(--selected);outline-offset:1px}.map-container.space-pressed,.map-container.space-pressed *{cursor:grab!important}.map-container.space-pressed:active,.map-container.space-pressed:active *{cursor:grabbing!important}.map-container .hyper-link{margin-left:.3em;text-decoration:none}.map-container me-main>me-wrapper>me-parent>me-epd{top:calc(50% - 9px)}.map-container me-epd{top:calc(100% - 9px)}.map-container .lhs{direction:rtl}.map-container .lhs>me-wrapper>me-parent>me-epd{left:-10px}.map-container .lhs me-epd{left:5px}.map-container .lhs me-tpc{direction:ltr}.map-container .rhs>me-wrapper>me-parent>me-epd{right:-10px}.map-container .rhs me-epd{right:5px}.map-container me-nodes.down{flex-direction:column}.map-container me-nodes.down me-main{flex-direction:row;justify-content:center;align-items:flex-start;display:flex}.map-container me-nodes.down>me-main>me-wrapper{margin:var(--main-gap-x) var(--main-gap-y)}.map-container me-nodes.down me-wrapper{flex-direction:column;align-items:center;display:flex}.map-container me-nodes.down me-children{margin-top:var(--node-gap-x);flex-direction:row;justify-content:center;align-items:flex-start;display:flex}.map-container me-nodes.down me-parent{padding:0 var(--node-gap-y);margin:0}.map-container me-nodes.down me-children me-parent>me-tpc{border:1px solid var(--main-color);background-color:var(--main-bgcolor);border-radius:6px;padding:6px 12px}.map-container me-nodes.down me-epd{top:calc(100% - 9px);left:calc(50% - 9px);right:auto}.map-container me-nodes.down>me-main>me-wrapper>me-parent>me-epd{top:calc(100% - 9px);left:calc(50% - 9px);right:auto}.map-container me-nodes.down .insert-preview.before{width:14px;height:100%;top:0;left:-14px}.map-container me-nodes.down .insert-preview.after{width:14px;height:100%;top:0;left:auto;right:-14px}.map-container{background-color:var(--bgcolor)}.map-container .map-canvas{pointer-events:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;width:-webkit-fit-content;width:fit-content;position:relative;transform:scale(1)}.map-container .map-canvas me-nodes{width:-webkit-max-content;width:max-content;height:-webkit-max-content;height:max-content;padding:var(--map-padding);justify-content:center;align-items:center;display:flex;position:relative}.map-container me-main>me-wrapper{margin:var(--main-gap-y) var(--main-gap-x);position:relative}.map-container me-main>me-wrapper>me-parent{margin:10px;padding:0}.map-container me-main>me-wrapper>me-parent>me-tpc{border-radius:var(--main-radius);background-color:var(--main-bgcolor);border:var(--main-border,2px solid var(--main-color));color:var(--main-color);padding:8px 25px}.map-container me-wrapper{width:-webkit-fit-content;width:fit-content;display:block}.map-container me-children{vertical-align:middle;display:inline-block}.map-container me-parent{vertical-align:middle;display:inline-block}.map-container me-root{z-index:10;margin:45px 0;position:relative}.map-container me-root me-tpc{color:var(--root-color);border-radius:var(--root-radius);border:var(--root-border-color) 2px solid;background-color:var(--root-bgcolor);padding:10px 30px;font-size:25px}.map-container me-parent{cursor:pointer;padding:6px var(--node-gap-x);margin-top:var(--node-gap-y);z-index:10;position:relative}.map-container me-parent me-tpc{color:var(--color);padding:var(--topic-padding);border-radius:3px;position:relative}.map-container me-parent me-tpc .insert-preview{z-index:9;width:100%;position:absolute;left:0}.map-container me-parent me-tpc .show{pointer-events:none;opacity:.7;background:#7ad5ff;border-radius:3px}.map-container me-parent me-tpc .before{height:14px;top:-14px}.map-container me-parent me-tpc .in{height:100%;top:0}.map-container me-parent me-tpc .after{height:14px;bottom:-14px}.map-container me-parent me-epd{opacity:.8;pointer-events:all;z-index:9;background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+CjxzdmcgdD0iMTY1NjY1NDcxNzI0MiIgY2xhc3M9Imljb24iIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIHZlcnNpb249IjEuMSIKICAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+CiAgICA8cGF0aCBkPSJNNTEyIDc0LjY2NjY2N0MyNzAuOTMzMzMzIDc0LjY2NjY2NyA3NC42NjY2NjcgMjcwLjkzMzMzMyA3NC42NjY2NjcgNTEyUzI3MC45MzMzMzMgOTQ5LjMzMzMzMyA1MTIgOTQ5LjMzMzMzMyA5NDkuMzMzMzMzIDc1My4wNjY2NjcgOTQ5LjMzMzMzMyA1MTIgNzUzLjA2NjY2NyA3NC42NjY2NjcgNTEyIDc0LjY2NjY2N3oiIHN0cm9rZS13aWR0aD0iNTQiIHN0cm9rZT0nYmxhY2snIGZpbGw9J3doaXRlJyA+PC9wYXRoPgogICAgPHBhdGggZD0iTTY4Mi42NjY2NjcgNDgwaC0xMzguNjY2NjY3VjM0MS4zMzMzMzNjMC0xNy4wNjY2NjctMTQuOTMzMzMzLTMyLTMyLTMycy0zMiAxNC45MzMzMzMtMzIgMzJ2MTM4LjY2NjY2N0gzNDEuMzMzMzMzYy0xNy4wNjY2NjcgMC0zMiAxNC45MzMzMzMtMzIgMzJzMTQuOTMzMzMzIDMyIDMyIDMyaDEzOC42NjY2NjdWNjgyLjY2NjY2N2MwIDE3LjA2NjY2NyAxNC45MzMzMzMgMzIgMzIgMzJzMzItMTQuOTMzMzMzIDMyLTMydi0xMzguNjY2NjY3SDY4Mi42NjY2NjdjMTcuMDY2NjY3IDAgMzItMTQuOTMzMzMzIDMyLTMycy0xNC45MzMzMzMtMzItMzItMzJ6Ij48L3BhdGg+Cjwvc3ZnPg==);background-position:50%;background-repeat:no-repeat;background-size:contain;width:18px;height:18px;position:absolute}.map-container me-parent me-epd.minus{opacity:0;transition:opacity .3s;background-image:url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+CjxzdmcgdD0iMTY1NjY1NTU2NDk4NSIgY2xhc3M9Imljb24iIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIHZlcnNpb249IjEuMSIKICAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+CiAgICA8cGF0aCBkPSJNNTEyIDc0LjY2NjY2N0MyNzAuOTMzMzMzIDc0LjY2NjY2NyA3NC42NjY2NjcgMjcwLjkzMzMzMyA3NC42NjY2NjcgNTEyUzI3MC45MzMzMzMgOTQ5LjMzMzMzMyA1MTIgOTQ5LjMzMzMzMyA5NDkuMzMzMzMzIDc1My4wNjY2NjcgOTQ5LjMzMzMzMyA1MTIgNzUzLjA2NjY2NyA3NC42NjY2NjcgNTEyIDc0LjY2NjY2N3oiIHN0cm9rZS13aWR0aD0iNTQiIHN0cm9rZT0nYmxhY2snIGZpbGw9J3doaXRlJyA+PC9wYXRoPgogICAgPHBhdGggZD0iTTY4Mi42NjY2NjcgNTQ0SDM0MS4zMzMzMzNjLTE3LjA2NjY2NyAwLTMyLTE0LjkzMzMzMy0zMi0zMnMxNC45MzMzMzMtMzIgMzItMzJoMzQxLjMzMzMzNGMxNy4wNjY2NjcgMCAzMiAxNC45MzMzMzMgMzIgMzJzLTE0LjkzMzMzMyAzMi0zMiAzMnoiPjwvcGF0aD4KPC9zdmc+)!important}@media (hover:hover){.map-container me-parent me-epd.minus:hover{opacity:.8}}@media (hover:none){.map-container me-parent me-epd.minus{opacity:.8}}.map-container .icon{vertical-align:-.15em;fill:currentColor;width:1em;height:1em;overflow:hidden}.map-container .lines,.map-container .summary,.map-container .subLines,.map-container .topiclinks,.map-container .linkcontroller{width:100%;height:102%;position:absolute;top:0;left:0}.map-container .topiclinks,.map-container .linkcontroller,.map-container .summary{pointer-events:none;z-index:20}.map-container .summary>g{cursor:pointer;pointer-events:stroke;z-index:20}.map-container .topiclinks>g{cursor:pointer;pointer-events:stroke;z-index:20}.map-container .label-container{z-index:21}.map-container .lines,.map-container .subLines{pointer-events:none}.map-container #input-box{-webkit-user-select:auto;-ms-user-select:auto;user-select:auto;pointer-events:auto;width:-webkit-max-content;width:max-content;max-width:35em;color:var(--color);background-color:var(--bgcolor);z-index:100;direction:ltr;border-radius:3px;outline:1px solid #ccc;position:absolute;top:0;left:0}.map-container me-tpc{white-space:pre-wrap;pointer-events:all;max-width:35em;display:block}.map-container me-tpc>*{pointer-events:none}.map-container me-tpc>a{pointer-events:auto}.map-container me-tpc>iframe{pointer-events:auto}.map-container me-tpc>.text{display:inline-block}.map-container me-tpc>.text a{pointer-events:auto}.map-container me-tpc>img{object-fit:cover;margin-bottom:8px;display:block}.map-container .circle{pointer-events:all;z-index:50;cursor:pointer;background:#757575;border:2px solid #fff;border-radius:100%;width:10px;height:10px;margin-top:-5px;margin-left:-5px;position:absolute}.map-container .circle:before{content:"";background:0 0;width:30px;height:30px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.map-container .tags{direction:ltr}.map-container .tags span{color:#276f86;background:#d6f0f8;border-radius:3px;margin:2px 4px 0 0;padding:2px 4px;font-size:12px;line-height:1.3em;display:inline-block}.map-container .icons{direction:ltr;margin-left:5px;display:inline-block}.map-container .icons span{line-height:1.3em;display:inline-block}.map-container .mind-elixir-ghost{box-sizing:content-box;opacity:.7;background-color:var(--main-bgcolor);border:2px solid var(--main-color);color:var(--main-color);white-space:nowrap;text-overflow:ellipsis;pointer-events:none;z-index:1000;border-radius:6px;width:-webkit-fit-content;width:fit-content;max-width:200px;padding:8px 16px;display:none;position:absolute;top:0;left:0;overflow:hidden}.map-container .selection-area{background:#4f90f22d;border:1px solid #4f90f2}.map-container .svg-label{overflow-wrap:break-word;-webkit-hyphens:auto;-ms-hyphens:auto;hyphens:auto;pointer-events:auto;cursor:pointer;z-index:10;width:-webkit-max-content;width:max-content;line-height:1.2;position:absolute}.map-container .svg-label p{max-width:200px}.map-container .svg-label{padding:var(--topic-padding);border-radius:3px}.map-container .svg-label[data-type=arrow]{background-color:var(--main-bgcolor-transparent)}.map-container h1{color:var(--selected);font-size:1.5rem;font-weight:700}.map-container h2{color:var(--selected);font-size:1.25rem;font-weight:600}.map-container h3{color:var(--selected);font-size:1.125rem;font-weight:600}.map-container h4{color:var(--selected);font-size:1rem;font-weight:600}.map-container h5{color:var(--selected);font-size:.875rem;font-weight:600}.map-container h6{color:var(--selected);margin:.1rem 0;font-size:.875rem;font-style:italic;font-weight:500}.map-container strong.asterisk-emphasis,.map-container em{color:var(--selected)}.map-container strong.underscore-emphasis{background:#ffeb3b40;border-radius:.15em;padding:.05em .15em}.map-container a{color:var(--selected)}.map-container a:hover{color:var(--selected);text-decoration:underline}.map-container .context-menu{z-index:99;width:100%;height:100%;position:fixed;top:0;left:0}.map-container .context-menu .menu-list{color:var(--panel-color);border-radius:5px;margin:0;padding:0;list-style:none;position:fixed;overflow:hidden;box-shadow:0 12px 15px #0003}.map-container .context-menu .menu-list li{white-space:nowrap;background:var(--panel-bgcolor);border-bottom:1px solid var(--panel-border-color);cursor:pointer;min-width:200px;padding:6px 10px;overflow:hidden}.map-container .context-menu .menu-list li span{line-height:20px}.map-container .context-menu .menu-list li a{color:#333;text-decoration:none}.map-container .context-menu .menu-list li.disabled{display:none}.map-container .context-menu .menu-list li:hover{filter:brightness(.95)}.map-container .context-menu .menu-list li:last-child{border-bottom:0}.map-container .context-menu .menu-list li span:last-child{float:right}.map-container .context-menu .key{color:#333;background-color:#f1f1f1;border-radius:3px;padding:2px 5px;font-size:10px}.map-container .tips{color:var(--panel-color);background:var(--panel-bgcolor);opacity:.8;border-radius:5px;padding:5px 10px;font-weight:700;position:absolute;bottom:28px;left:50%;transform:translate(-50%)}.mind-elixir-toolbar{color:var(--panel-color);background:var(--panel-bgcolor);border-radius:5px;padding:10px;position:absolute;box-shadow:0 1px 2px #0003}.mind-elixir-toolbar svg{display:inline-block}.mind-elixir-toolbar span:active{opacity:.5}.mind-elixir-toolbar.rb{bottom:20px;right:20px}.mind-elixir-toolbar.rb span+span{margin-left:10px}.mind-elixir-toolbar.lt{font-size:20px;top:20px;left:20px}.mind-elixir-toolbar.lt span{display:block}.mind-elixir-toolbar.lt span+span{margin-top:10px}.pursuit-map{--pm-ink: #30453e;--pm-muted: #78837e;--pm-border: #e2e7e0;--pm-accent: #347d6a;display:flex;flex-direction:column;min-height:480px;height:calc(100dvh - 116px);overflow:hidden;color:var(--pm-ink);background:#fdfdfb;border:1px solid var(--pm-border);border-radius:10px;font:14px/1.45 ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif}.pursuit-map[hidden],.pursuit-map [hidden]{display:none!important}.pursuit-map button,.pursuit-map input,.pursuit-map textarea{font:inherit}.pursuit-map button{min-width:30px;min-height:30px;padding:4px 9px;color:var(--pm-ink);background:transparent;border:1px solid transparent;border-radius:5px;cursor:pointer;white-space:nowrap}.pursuit-map button:hover,.pursuit-map summary:hover{background:#edf2ec;border-color:transparent}.pursuit-map button:disabled{opacity:.35;cursor:default}.pursuit-map button:focus-visible,.pursuit-map summary:focus-visible{outline:2px solid var(--pm-accent);outline-offset:2px}.pursuit-map input,.pursuit-map textarea{box-sizing:border-box}.pm-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:6px 15px;padding:8px 12px;background:#fff;border-bottom:1px solid var(--pm-border);z-index:35}.pm-tool-group{display:flex;align-items:center;gap:3px}.pm-view-tools{margin-left:auto}.pm-divider{width:1px;height:17px;background:var(--pm-border);margin:0 5px}.pm-save-status{color:var(--pm-muted);font-size:11px;margin-right:7px;white-space:nowrap}.pm-save-status:before{content:"";display:inline-block;width:5px;height:5px;margin-right:6px;border-radius:50%;background:#7aa189;vertical-align:2px}.pm-save-status.pm-saving:before{background:#c08d45;animation:pm-pulse 1s ease-in-out infinite alternate}@keyframes pm-pulse{to{opacity:.35}}.pm-toolbar [aria-pressed=true]{color:#a67228;background:#f7f0df}.pm-more,.pm-help{position:relative}.pm-more summary,.pm-help summary{display:grid;place-items:center;height:30px;min-width:30px;border-radius:5px;cursor:pointer;list-style:none}.pm-more summary::-webkit-details-marker,.pm-help summary::-webkit-details-marker{display:none}.pm-more summary{font-weight:700;font-size:18px;letter-spacing:1px}.pm-help summary{font-size:12px;color:var(--pm-muted)}.pm-menu,.pm-help>div{position:absolute;top:37px;right:0;min-width:235px;padding:7px;background:#fff;border:1px solid var(--pm-border);box-shadow:0 8px 28px #223d241a;border-radius:8px;z-index:60}.pm-menu{display:grid;gap:2px}.pm-menu button{display:flex;justify-content:space-between;gap:15px;text-align:left}.pm-menu kbd{color:var(--pm-muted);font:inherit;font-size:11px}.pm-menu .pm-danger{color:#a64f3e}.pm-help>div{width:320px;padding:16px}.pm-help strong{display:block;margin-bottom:7px}.pm-help p{margin:8px 0;color:var(--pm-muted);font-size:12px;line-height:1.6}.pm-stage{flex:1;position:relative;min-height:0;overflow:hidden}.pm-canvas{width:100%;height:100%}.pm-canvas .map-container{font-family:inherit;background:#fdfdfb}.pm-canvas .map-container me-root{margin:30px 5px}.pm-canvas .map-container me-root me-tpc{font-size:25px;font-weight:650;border:0;padding:10px 15px}.pm-canvas .map-container me-main>me-wrapper>me-parent{margin:8px 4px}.pm-canvas .map-container me-main>me-wrapper>me-parent>me-tpc{padding:8px 14px}.pm-canvas .map-container me-tpc{max-width:34em;line-height:1.4;border-radius:5px}.pm-canvas .map-container me-tpc.selected{outline:2px solid #4d8d7b;outline-offset:2px}.pm-canvas .map-container me-tpc.pm-readonly{color:#7d847e!important;border-bottom:1px dashed #c3cac1!important}.pm-canvas .map-container me-tpc.pm-search-hit{box-shadow:0 0 0 4px #f2d88883}.pm-canvas .map-container me-epd{width:15px;height:15px;opacity:.38;cursor:pointer}.pm-canvas .map-container me-epd.minus{opacity:.15}.pm-canvas .map-container me-parent:hover>me-epd,.pm-canvas .map-container me-epd:hover{opacity:.85}.pm-canvas .map-container #input-box{outline:2px solid var(--pm-accent);min-height:28px;white-space:pre;max-width:45em;background:#fff!important;color:var(--pm-ink);box-shadow:0 3px 12px #263a2820}.pm-canvas .map-container .pm-node-marker{display:inline-flex;align-items:center;justify-content:center;min-width:16px;min-height:18px;margin:-2px -2px -2px 4px;padding:0 2px;border:0;border-radius:3px;color:#8d9e91;font:12px/1 sans-serif;vertical-align:1px;pointer-events:auto}.pm-canvas .map-container [data-map-marker=focus]{color:#b48938}.pm-canvas .map-container .insert-preview.show{background:#4a8c7775;outline:1px solid #357763}.pm-canvas .map-container .insert-preview.before,.pm-canvas .map-container .insert-preview.after{height:4px;background:#397b65;opacity:1}.pm-canvas .map-container .insert-preview.before{top:-7px}.pm-canvas .map-container .insert-preview.after{bottom:-7px}.pm-canvas .map-container .mind-elixir-ghost{background:#f4f8f1;border:1px solid #658c75;padding:5px 10px;font-size:14px}.pm-canvas-hint{position:absolute;bottom:12px;left:17px;margin:0;color:#9ba39b;font-size:11px;pointer-events:none}.pm-canvas-hint span{margin-left:18px}.pm-zoom{position:absolute;right:14px;bottom:12px;display:flex;align-items:center;gap:2px;background:#ffffffed;border:1px solid var(--pm-border);border-radius:7px}.pm-zoom span{min-width:38px;text-align:center;color:var(--pm-muted);font-size:11px}.pm-search{position:absolute;top:13px;left:50%;transform:translate(-50%);display:flex;align-items:center;gap:3px;width:min(450px,calc(100% - 25px));background:#fff;border:1px solid #c9d8cb;border-radius:8px;box-shadow:0 5px 24px #2039211c;padding:5px 7px;z-index:31}.pm-search input{width:100%;min-width:70px;min-height:30px;padding:4px 6px;border:0;background:transparent;outline:none;color:var(--pm-ink)}.pm-search-count{flex-shrink:0;font-size:11px;color:var(--pm-muted)}.pm-note,.pm-relations,.pm-recovery{position:absolute;z-index:32;top:14px;right:14px;width:min(365px,calc(100% - 28px));background:#fff;border:1px solid #d4dfd2;border-radius:10px;box-shadow:0 9px 36px #29452a22}.pm-note{display:flex;flex-direction:column;height:min(510px,calc(100% - 64px))}.pm-note header,.pm-relations header{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:13px 13px 11px 17px;border-bottom:1px solid #edf0e9}.pm-note header small{color:#8c998d;letter-spacing:.1em;font-size:9px}.pm-note h2{margin:4px 0 0;font-size:15px;line-height:1.5;font-weight:550;word-break:break-word}.pm-note textarea{flex:1;min-height:110px;width:100%;border:0;border-radius:0;padding:16px;resize:none;background:#fff;color:#35453b;outline:none;line-height:1.75;font:13px/1.7 ui-monospace,SFMono-Regular,Consolas,Microsoft YaHei,monospace}.pm-note textarea:read-only{background:#fafbf8}.pm-note-status{padding:5px 15px;color:var(--pm-muted);font-size:11px}.pm-note-status.pm-error{background:#fbf1e5;color:#9a623c}.pm-note footer{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:9px 12px;border-top:1px solid #edf0e9}.pm-note footer span{flex:1;color:#96a091;font-size:9px}.pm-note footer [data-command=save-note]{color:#fff;background:#407d67}.pm-note footer [data-command=discard-note]{font-size:11px;color:#996346}.pm-relations{width:min(295px,calc(100% - 28px))}.pm-relations header{align-items:center;padding:9px 12px}.pm-relations>div{padding:10px 14px;display:grid;gap:3px;color:var(--pm-muted);font-size:12px}.pm-relations>div button{text-align:left;white-space:normal}.pm-recovery{left:14px;right:auto;padding:12px;max-height:45%;overflow:auto}.pm-recovery strong{font-size:12px;color:#9b663b}.pm-recovery>div{display:flex;align-items:center;gap:4px;margin-top:8px}.pm-recovery>div span{flex:1;font-size:12px;overflow-wrap:anywhere;user-select:text}.pm-recovery button{color:#9b663b;font-size:11px;border-color:#eee1d0}.pm-toast{position:absolute;bottom:53px;left:50%;transform:translate(-50%);display:flex;align-items:center;gap:10px;max-width:min(720px,calc(100% - 28px));padding:8px 10px 8px 15px;border:1px solid #ccd7c9;border-radius:8px;color:#42523f;background:#fffef6;box-shadow:0 5px 20px #2b3c2720;z-index:50;font-size:12px}.pm-toast span{overflow-wrap:anywhere}.pm-toast button{color:#397965;font-weight:600}.pm-diagnostics{display:flex;gap:20px;align-items:flex-start;padding:9px 15px;background:#fcf5e7;color:#8f6637;font-size:12px;border-bottom:1px solid #f1e6d1}.pm-diagnostics details{min-width:0}.pm-diagnostics summary{cursor:pointer}.pm-diagnostics p{color:inherit;margin:6px 0;overflow-wrap:anywhere}@media (max-width: 1000px){.pm-toolbar{gap:3px 6px;padding:7px}.pm-node-tools button span{display:none}.pm-canvas-hint span:last-child{display:none}}@media (max-width: 680px){.pursuit-map{height:calc(100dvh - 160px);min-height:440px;border-radius:0}.pm-toolbar{font-size:12px}.pm-view-tools{margin-left:0}.pm-canvas-hint{font-size:10px}.pm-canvas-hint span{display:none}}@media (prefers-reduced-motion: reduce){.pm-save-status.pm-saving:before{animation:none}} diff --git a/rightmemory/web/static/pursuit-map.js b/rightmemory/web/static/pursuit-map.js new file mode 100644 index 0000000..9dc50d7 --- /dev/null +++ b/rightmemory/web/static/pursuit-map.js @@ -0,0 +1,73 @@ +var L="__pursuit_virtual_root__",Xe="__pursuit_draft_";function N(t){return new Map(t.items.map(e=>[e.id,e]))}function B(t){return t.root_ids.length===1?t.root_ids[0]:L}function K(t,e){return e===null||e===L?t.root_ids:N(t).get(e)?.child_ids??[]}function Ge(t,e){let n=N(t),i=new Set,o=[e];for(;o.length;){let r=o.pop();i.has(r)||(i.add(r),o.push(...n.get(r)?.child_ids??[]))}return i}function Te(t,e,n,i){let o=N(t);if(!o.get(e)?.editable)throw new Error("This item cannot be moved.");if(n&&!o.get(n)?.editable)throw new Error("Choose an editable destination.");if(n&&Ge(t,e).has(n))throw new Error("A direction cannot be moved inside itself.");if(i===e)throw new Error("Choose a different insertion position.");if(i&&!K(t,n).includes(i))throw new Error("The insertion point has changed.")}function te(t,e,n){let i={...t,items:t.items.map(l=>({...l,child_ids:[...l.child_ids],edges:l.edges.map(c=>[...c])})),root_ids:[...t.root_ids],focus_ids:[...t.focus_ids]},o=N(i),r=l=>l===null?i.root_ids:o.get(l).child_ids,s=(l,c,d)=>{let p=r(c);if(d&&!p.includes(d))throw new Error("The insertion point has changed.");p.splice(d?p.indexOf(d)+1:0,0,l)};if(e.type==="create"){if(!n)throw new Error("A local identity is needed while saving a new direction.");if(e.parent_id&&!o.get(e.parent_id)?.editable)throw new Error("Choose an editable parent.");let l={id:n,title:e.title,body:"",parent_id:e.parent_id,child_ids:[],edges:[],focused:!1,editable:!0};return i.items.push(l),o.set(l.id,l),s(l.id,l.parent_id,e.after_id),i}let a=o.get(e.id);if(!a?.editable)throw new Error("This item is no longer editable. Reload the map.");switch(e.type){case"rename":a.title=e.title;break;case"edit_body":a.body=e.body;break;case"set_focus":a.focused=e.focused,i.focus_ids=i.focus_ids.filter(l=>l!==a.id),a.focused&&i.focus_ids.push(a.id);break;case"move":{Te(t,a.id,e.parent_id,e.after_id);let l=r(a.parent_id);l.splice(l.indexOf(a.id),1),a.parent_id=e.parent_id,s(a.id,a.parent_id,e.after_id);break}case"delete":{let l=Ge(i,a.id),c=r(a.parent_id);c.splice(c.indexOf(a.id),1),i.items=i.items.filter(d=>!l.has(d.id)),i.focus_ids=i.focus_ids.filter(d=>!l.has(d)),i.items.forEach(d=>{d.edges=d.edges.filter(p=>!l.has(p[1]))});break}}return i}function le(t,e,n){let i={...t};return"id"in i&&i.id===e&&(i.id=n),"parent_id"in i&&i.parent_id===e&&(i.parent_id=n),"after_id"in i&&i.after_id===e&&(i.after_id=n),i}function Je(t,e){let n=N(t),i=n.get(e);if(!i?.parent_id)return null;let o=n.get(i.parent_id);return{type:"move",id:e,parent_id:o.parent_id,after_id:o.id}}function Ze(t,e){let n=N(t).get(e);if(!n)return null;let i=K(t,n.parent_id),o=i.indexOf(e);return i[o+1]??i[o-1]??n.parent_id??L}var ne=class extends Error{constructor(n,i=0,o){super(n);this.status=i;this.snapshot=o}},ce=class{constructor(e,n){this.transport=n;this.confirmed=e}confirmed;pending=[];running=!1;historyBusy=!1;recovering=!1;listeners=new Set;undoStack=[];redoStack=[];get snapshot(){return this.pending.reduce((e,n)=>te(e,n.operation,n.temporaryId),this.confirmed)}get pendingCount(){return this.pending.length+Number(this.historyBusy||this.recovering)}get canUndo(){return!!this.undoStack.length&&!this.pendingCount}get canRedo(){return!!this.redoStack.length&&!this.pendingCount}subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}emit(e={}){let n={snapshot:this.snapshot,pending:this.pendingCount,...e};this.listeners.forEach(i=>i(n))}enqueue(e,n){if(!this.confirmed.writable)return Promise.reject(new Error("The map is read-only. Resolve its diagnostics before editing."));if(this.historyBusy)return Promise.reject(new Error("Wait for undo or redo to finish."));if(this.recovering)return Promise.reject(new Error("Wait for the map to reload after the failed save."));try{te(this.snapshot,e,n)}catch(o){return Promise.reject(o)}let i=new Promise((o,r)=>{this.pending.push({operation:e,temporaryId:n,resolve:o,reject:r})});return this.emit(),this.drain(),i}async reload(){if(this.pendingCount)return;let e=await this.transport.load();if(!this.pendingCount){if(e.root_key!==this.confirmed.root_key)throw new Error("The active root changed. Reopen the Pursuit Map.");e.revision!==this.confirmed.revision&&(this.undoStack=[],this.redoStack=[]),this.confirmed=e,this.emit()}}async drain(){if(!this.running){this.running=!0;try{for(;this.pending.length;){let e=this.pending[0];try{let n=await this.transport.mutate(this.confirmed.revision,e.operation);if(n.snapshot.root_key!==this.confirmed.root_key)throw new Error("The active root changed. Reopen the Pursuit Map before editing.");let i;if(e.operation.type==="create"&&e.temporaryId){let o=n.selected_id??n.snapshot.items.find(r=>!this.confirmed.items.some(s=>s.id===r.id))?.id;if(!o)throw new Error("The saved direction was not returned by the server. Reload before continuing.");i={from:e.temporaryId,to:o}}else if("id"in e.operation){let o=e.operation.id;n.selected_id&&n.selected_id!==o&&!n.snapshot.items.some(r=>r.id===o)&&(i={from:o,to:n.selected_id})}this.pending.shift(),this.confirmed=n.snapshot,i&&this.pending.forEach(o=>{o.operation=le(o.operation,i.from,i.to)}),n.undoable&&n.commit&&this.undoStack.push(n.commit),n.commit&&(this.redoStack=[]),e.resolve(n),this.emit({result:n,remapped:i})}catch(n){let i=n instanceof Error?n:new Error(String(n)),o=this.pending.splice(0);this.undoStack=[],this.redoStack=[],this.recovering=!0,await this.recover(i),this.recovering=!1,this.emit({error:i}),o.forEach(r=>r.reject(i))}}}finally{this.running=!1}}}async recover(e){if(e instanceof ne&&e.snapshot?.root_key===this.confirmed.root_key){this.confirmed=e.snapshot;return}try{let n=await this.transport.load();n.root_key===this.confirmed.root_key&&(this.confirmed=n)}catch{}}async history(e){let n=e==="undo"?this.undoStack:this.redoStack,i=e==="undo"?this.redoStack:this.undoStack;if(!n.length||this.pendingCount)return null;this.historyBusy=!0,this.emit();try{let o=await this.transport.history(e,this.confirmed.revision,n.at(-1));return n.pop(),o.commit&&i.push(o.commit),this.confirmed=o.snapshot,this.historyBusy=!1,this.emit({result:o}),o}catch(o){let r=o instanceof Error?o:new Error(String(o));throw this.undoStack=[],this.redoStack=[],await this.recover(r),this.historyBusy=!1,this.emit({error:r}),r}}};var de=class{title=null;failedTitles=[];savingTitles=[];note=null;get dirty(){return!!this.title||!!this.failedTitles.length||!!this.savingTitles.length||!!this.note&&(this.note.text!==this.note.savedText||this.note.saving)}failedTitle(e,n){this.savingTitles=this.savingTitles.filter(i=>i!==e),this.failedTitles=this.failedTitles.filter(i=>i.id!==e.id),this.failedTitles.push({...e,error:n})}openNote(e,n){return this.note={id:e,text:n,savedText:n,saving:!1},this.note}noteSaved(e,n){this.note?.id===e&&(this.note.savedText=n,this.note.saving=!1,this.note.error=void 0)}noteFailed(e,n){this.note?.id===e&&(this.note.saving=!1,this.note.error=n)}reconcile(e){let n=this.note;if(!n||n.saving||n.text!==n.savedText||n.error)return;let i=e.items.find(o=>o.id===n.id);i&&(n.text=n.savedText=i.body)}remap(e,n){let i=o=>{o.id===e&&(o.id=n),o.operation=le(o.operation,e,n)};this.title&&i(this.title),this.failedTitles.forEach(i),this.savingTitles.forEach(i),this.note?.id===e&&(this.note.id=n)}};var _e={name:"Latte",type:"light",palette:["#dd7878","#ea76cb","#8839ef","#e64553","#fe640b","#df8e1d","#40a02b","#209fb5","#1e66f5","#7287fd"],cssVar:{"--node-gap-x":"30px","--node-gap-y":"10px","--main-gap-x":"65px","--main-gap-y":"45px","--root-radius":"30px","--main-radius":"20px","--root-color":"#ffffff","--root-bgcolor":"#4c4f69","--root-border-color":"rgba(0, 0, 0, 0)","--main-border":"","--main-color":"#444446","--main-bgcolor":"#ffffff","--main-bgcolor-transparent":"rgba(255, 255, 255, 0.8)","--topic-padding":"3px","--color":"#777777","--bgcolor":"#f6f6f6","--selected":"#4dc4ff","--accent-color":"#e64553","--panel-color":"#444446","--panel-bgcolor":"#ffffff","--panel-border-color":"#eaeaea","--map-padding":"50px 80px"}},Le={name:"Dark",type:"dark",palette:["#848FA0","#748BE9","#D2F9FE","#4145A5","#789AFA","#706CF4","#EF987F","#775DD5","#FCEECF","#DA7FBC"],cssVar:{"--node-gap-x":"30px","--node-gap-y":"10px","--main-gap-x":"65px","--main-gap-y":"45px","--root-radius":"30px","--main-radius":"20px","--root-color":"#ffffff","--root-bgcolor":"#2d3748","--root-border-color":"rgba(255, 255, 255, 0.1)","--main-border":"","--main-color":"#ffffff","--main-bgcolor":"#4c4f69","--main-bgcolor-transparent":"rgba(76, 79, 105, 0.8)","--topic-padding":"3px","--color":"#cccccc","--bgcolor":"#252526","--selected":"#4dc4ff","--accent-color":"#789AFA","--panel-color":"#ffffff","--panel-bgcolor":"#2d3748","--panel-border-color":"#696969","--map-padding":"50px 80px"}};function De(t){return t.replace(/&/g,"&").replace(/{if(t.parent=e,t.children)for(let n=0;n{if(t.expanded=e,t.children)if(n===void 0||n>0){let i=n!==void 0?n-1:void 0;t.children.forEach(o=>{J(o,e,i)})}else t.children.forEach(i=>{J(i,!1)})};function $e(t){if(t.id=q(),t.children)for(let e=0;e{if(e!=="parent")return n}))}var O=(t,e)=>{let n=0,i=0;for(;e&&e!==t;)n+=e.offsetLeft,i+=e.offsetTop,e=e.offsetParent;return{offsetLeft:n,offsetTop:i}},_=(t,e)=>{for(let n in e)t.setAttribute(n,e[n])},ge=t=>t?t.tagName==="ME-TPC":!1,Pe=t=>{let e=new Set(t.map(n=>n.nodeObj));return t.filter(n=>n.nodeObj.parent).filter(n=>{let i=n.nodeObj.parent;for(;i;){if(e.has(i))return!1;i=i.parent}return!0})},He=t=>{let e=/translate3d\(([^,]+),\s*([^,]+)/,n=t.match(e);return n?{x:parseFloat(n[1]),y:parseFloat(n[2])}:{x:0,y:0}},gt=function(t){for(let e=0;e{let n=t.x-e.x,i=t.y-e.y;return Math.sqrt(n*n+i*i)},et=function(t,e){if(!e)return xe(t),t;let n=t.querySelector(".insert-preview"),i=`insert-preview ${e} show`;return n||(n=document.createElement("div"),t.appendChild(n)),n.className=i,t},xe=function(t){if(!t)return;let e=t.querySelectorAll(".insert-preview");for(let n of e||[])n.remove()},pe=function(t,e){for(let n of e){let i=n.parentElement.parentElement.contains(t);if(!(t&&t.tagName==="ME-TPC"&&t!==n&&!i&&t.nodeObj.parent))return!1}return!0},Gt=function(t){let e=document.createElement("div");return e.className="mind-elixir-ghost",t.container.appendChild(e),e},Ae=class{mind;isMoving=!1;interval=null;speed=20;constructor(e){this.mind=e}move(e,n){this.isMoving||(this.isMoving=!0,this.interval=setInterval(()=>{this.mind.move(e*this.speed*this.mind.scaleVal,n*this.speed*this.mind.scaleVal)},100))}stop(){this.isMoving=!1,this.interval&&(clearInterval(this.interval),this.interval=null)}};function Jt(t){return{isDragging:!1,insertType:null,meet:null,ghost:Gt(t),edgeMoveController:new Ae(t),startX:0,startY:0,pointerId:null}}var Zt=5;function tt(t,e,n,i=!1){if(t.spacePressed)return!1;let o=n.target;if(o?.tagName!=="ME-TPC"||!o.nodeObj.parent)return!1;if(e.startX=n.clientX,e.startY=n.clientY,e.pointerId=n.pointerId,t.dragged=t.currentNodes,i){vt(t,e);let r=t.container.getBoundingClientRect();bt(e.ghost,n.clientX-r.x,n.clientY-r.y)}return!0}function bt(t,e,n){t.style.transform=`translate(${e-10}px, ${n-10}px)`,t.style.display="block"}function vt(t,e){let{dragged:n}=t;if(!n)return;let i=document.activeElement;i&&i.isContentEditable&&i.blur(),e.isDragging=!0,n.length>1?e.ghost.innerHTML=n.length+"":e.ghost.innerHTML=n[0].innerHTML;for(let o of n)o.parentElement.parentElement.style.opacity="0.5";t.panHelper.clear()}function Qt(t,e,n){let{dragged:i}=t;if(!i||e.pointerId!==n.pointerId)return;let o=n.clientX-e.startX,r=n.clientY-e.startY,s=Math.sqrt(o*o+r*r);if(!e.isDragging&&s>Zt&&vt(t,e),!e.isDragging)return;let a=t.container.getBoundingClientRect();bt(e.ghost,n.clientX-a.x,n.clientY-a.y),n.clientXa.x+a.width-50?e.edgeMoveController.move(-1,0):n.clientYa.y+a.height-50?e.edgeMoveController.move(0,-1):e.edgeMoveController.stop(),xe(e.meet);let l=12*t.scaleVal;if(t.direction===3){let d=document.elementFromPoint(n.clientX-l,n.clientY);if(pe(d,i)){e.meet=d;let p=d.getBoundingClientRect();n.clientX>p.x+p.width?e.insertType="after":e.insertType="in"}else{let p=document.elementFromPoint(n.clientX+l,n.clientY);if(pe(p,i)){e.meet=p;let m=p.getBoundingClientRect();n.clientXp+d.height?e.insertType="after":e.insertType="in"}else{let d=document.elementFromPoint(n.clientX,n.clientY+l);if(pe(d,i)){e.meet=d;let p=d.getBoundingClientRect().y;n.clientY{}}var z={LHS:"lhs",RHS:"rhs",DOWN:"down"},nn=function(){this.nodes.innerHTML="",this.nodes.className=this.direction===3?"down":"";let t=this.createTopic(this.nodeData);Re.call(this,t,this.nodeData),t.draggable=!1;let e=document.createElement("me-root");e.appendChild(t);let n=this.nodeData.children||[];if(this.direction===2){let i=0,o=0;n.map(r=>{r.direction===0?i+=1:r.direction===1?o+=1:i<=o?(r.direction=0,i+=1):(r.direction=1,o+=1)})}on(this,n,e)},on=function(t,e,n){if(t.direction===3){let r=document.createElement("me-main");r.className=z.DOWN;for(let s=0;s`${De(i)}`).join(""),t.appendChild(n),t.icons=n}else t.icons&&(t.icons=void 0);if(e.tags&&e.tags.length){let n=document.createElement("div");n.className="tags",e.tags.forEach(i=>{let o=document.createElement("span");typeof i=="string"?o.textContent=i:(o.textContent=i.text,i.className&&(o.className=i.className),i.style&&Object.assign(o.style,i.style)),n.appendChild(o)}),t.appendChild(n),t.tags=n}else t.tags&&(t.tags=void 0)},sn=function(t,e){let n=document.createElement("me-wrapper"),{p:i,tpc:o}=this.createParent(t);if(n.appendChild(i),!e&&t.children&&t.children.length>0){let r=Be(t.expanded);if(i.appendChild(r),t.expanded!==!1){let s=rn(this,t.children);n.appendChild(s)}}return{grp:n,top:i,tpc:o}},an=function(t){let e=document.createElement("me-parent"),n=this.createTopic(t);return Re.call(this,n,t),e.appendChild(n),{p:e,tpc:n}},ln=function(t){let e=document.createElement("me-children");return e.append(...t),e},cn=function(t){let e=document.createElement("me-tpc");return e.nodeObj=t,e.dataset.nodeid="me"+t.id,e};function xt(t){let e=document.createRange();e.selectNodeContents(t);let n=window.getSelection();n&&(n.removeAllRanges(),n.addRange(e))}var dn=function(t){if(!t)return;let e=document.createElement("div"),n=t.nodeObj,i=n.topic,{offsetLeft:o,offsetTop:r}=O(this.nodes,t);this.nodes.appendChild(e),e.id="input-box",e.textContent=i,e.contentEditable="plaintext-only",e.spellcheck=!1;let s=getComputedStyle(t);e.style.cssText=` + left: ${o}px; + top: ${r}px; + min-width:${t.offsetWidth-8}px; + color:${s.color}; + font-size:${s.fontSize}; + padding:${s.padding}; + margin:${s.margin}; + background-color:${s.backgroundColor!=="rgba(0, 0, 0, 0)"&&s.backgroundColor}; + border: ${s.border}; + border-radius:${s.borderRadius}; `,this.direction===0&&(e.style.right="0"),t.style.opacity="0",xt(e),this.bus.fire("operation",{name:"beginEdit",obj:t.nodeObj}),e.addEventListener("keydown",a=>{if(a.stopPropagation(),a.isComposing)return;let l=a.key;if(l==="Enter"||l==="Tab"){if(a.shiftKey)return;a.preventDefault(),e.blur(),this.container.focus()}else l==="Escape"&&(a.preventDefault(),e.textContent=i,e.blur(),this.container.focus())}),e.addEventListener("blur",()=>{if(!e)return;t.style.opacity="1";let a=e.innerText?.trim()||"";e.remove(),!(a===i||a==="")&&(n.topic=a,this.markdown?t.text.innerHTML=this.markdown(n.topic,n):t.text.textContent=a,this.linkDiv(),this.bus.fire("operation",{name:"finishEdit",obj:n,origin:i}))})},Be=function(t){let e=document.createElement("me-epd");return e.expanded=t!==!1,e.className=t!==!1?"minus":"",e},we=t=>{let e=t.parent?.children,n=e?.indexOf(t)??0;return{siblings:e,index:n}};function wt(t){let{siblings:e,index:n}=we(t);return e===void 0?0:(e.splice(n,1),e.length)}function pn(t,e,n){let{siblings:i,index:o}=we(n);i!==void 0&&(e==="before"?i.splice(o,0,t):i.splice(o+1,0,t))}function hn(t,e){let{siblings:n,index:i}=we(t);n!==void 0&&(n[i]=e,e.children=[t])}function mn(t,e,n){if(wt(e),n.parent?.parent||(e.direction=n.direction),t==="in")n.children?n.children.push(e):n.children=[e];else{e.direction!==void 0&&(e.direction=n.direction);let{siblings:i,index:o}=we(n);if(i===void 0)return;t==="before"?i.splice(o,0,e):i.splice(o+1,0,e)}}var un=function({map:t,direction:e},n){if(e===0)return 0;if(e===1)return 1;if(e===3)return 3;if(e===2){let i=t.querySelector(".lhs")?.childElementCount||0,o=t.querySelector(".rhs")?.childElementCount||0;return i<=o?(n.direction=0,0):(n.direction=1,1)}},Mt=function(t,e,n){let i=n.children[0].children[0],o=e.parentElement;if(o.tagName==="ME-PARENT"){if(oe(i),o.children[1])o.nextSibling.appendChild(n);else{let r=t.createChildren([n]);o.appendChild(Be(!0)),o.insertAdjacentElement("afterend",r)}t.linkDiv(n.offsetParent)}else if(o.tagName==="ME-ROOT"){let r=un(t,i.nodeObj);r===3?t.container.querySelector("me-main.down")?.appendChild(n):r===0?t.container.querySelector(".lhs")?.appendChild(n):t.container.querySelector(".rhs")?.appendChild(n),t.linkDiv()}},fn=function(t,e){let n=t.parentNode;if(e===0){let i=n.parentNode.parentNode;i.tagName!=="ME-MAIN"&&(i.previousSibling.children[1].remove(),i.remove())}n.parentNode.remove()},Et={before:"beforebegin",after:"afterend"},oe=function(t){let e=t.parentElement.parentElement.lastElementChild;e?.tagName==="svg"&&e?.remove()},gn=function(t,e){let n=t.nodeObj,i=Z(n);i.style&&e.style&&(e.style=Object.assign(i.style,e.style));let o=Object.assign(n,e);Re.call(this,t,o),this.linkDiv(),this.bus.fire("operation",{name:"reshapeNode",obj:o,origin:i})},Ye=function(t,e,n){if(!e)return null;let i=e.nodeObj;i.expanded===!1&&(t.expandNode(e,!0),e=t.findEle(i.id));let o=n||t.generateNewObj();i.children?i.children.push(o):i.children=[o],F(t.nodeData);let{grp:r,top:s}=t.createWrapper(o);return Mt(t,e,r),{newTop:s,newNodeObj:o}},bn=function(t,e,n){let i=e||this.currentNode;if(!i)return;let o=i.nodeObj;if(o.parent){if(!o.parent?.parent&&this.direction===2){let c=this.map.querySelector(".lhs")?.childElementCount||0,d=this.map.querySelector(".rhs")?.childElementCount||0;if(!c||!d){this.addChild(this.findEle(o.parent.id),n);return}}}else{this.addChild();return}let r=n||this.generateNewObj();if(!o.parent?.parent){let c=i.closest("me-main").className===z.LHS?0:1;r.direction=c}pn(r,t,o),F(this.nodeData);let s=i.parentElement,{grp:a,top:l}=this.createWrapper(r);s.parentElement.insertAdjacentElement(Et[t],a),this.linkDiv(a.offsetParent),n||this.editTopic(l.firstChild),this.bus.fire("operation",{name:"insertSibling",type:t,obj:r}),this.selectNode(l.firstChild,!0)},vn=function(t,e){let n=t||this.currentNode;if(!n)return;oe(n);let i=n.nodeObj;if(!i.parent)return;let o=e||this.generateNewObj();if(!i.parent?.parent){let c=n.closest("me-main").className===z.LHS?0:1;o.direction=c}hn(i,o),F(this.nodeData);let r=n.parentElement.parentElement,{grp:s,top:a}=this.createWrapper(o,!0);a.appendChild(Be(!0)),r.insertAdjacentElement("afterend",s);let l=this.createChildren([r]);a.insertAdjacentElement("afterend",l),this.linkDiv(),e||this.editTopic(a.firstChild),this.bus.fire("operation",{name:"insertParent",obj:o}),this.selectNode(a.firstChild,!0)},yn=function(t,e){let n=t||this.currentNode;if(!n)return;let i=Ye(this,n,e);if(!i)return;let{newTop:o,newNodeObj:r}=i;this.bus.fire("operation",{name:"addChild",obj:r}),e||this.editTopic(o.firstChild),this.selectNode(o.firstChild,!0)},xn=function(t,e){let n=Z(t.nodeObj);$e(n);let i=Ye(this,e,n);if(!i)return;let{newNodeObj:o}=i;this.bus.fire("operation",{name:"copyNode",obj:o}),this.selectNode(this.findEle(o.id))},wn=function(t,e){let n=[];for(let i=0;ithis.findEle(i.id)))},St=function(t,e,n){let i=e.parent?.children;if(i===void 0)return;let o=t.direction===2&&!e.parent?.parent,r=l=>!o||l.direction===e.direction,s,a=!1;for(let l of i)if(r(l)){if(l===e){if(n===-1)return s?{to:s,type:"before"}:void 0;a=!0;continue}if(a)return{to:l,type:"after"};s=l}},Mn=function(t){let e=t||this.currentNode;if(!e)return;let n=e.nodeObj;if(!n.parent)return;let i=St(this,n,-1);i&&re([e],i.type,this.findEle(i.to.id),this)},En=function(t){let e=t||this.currentNode;if(!e)return;let n=e.nodeObj;if(!n.parent)return;let i=St(this,n,1);i&&re([e],i.type,this.findEle(i.to.id),this)},Sn=function(t){if(t=Pe(t),t.length===0)return;for(let n of t){let i=n.nodeObj,o=wt(i);fn(n,o)}let e=t[t.length-1];this.selectNode(this.findEle(e.nodeObj.parent.id)),this.linkDiv(),this.bus.fire("operation",{name:"removeNodes",objs:t.map(n=>n.nodeObj)})},re=(t,e,n,i)=>{t=Pe(t);let o=n.nodeObj;e==="in"&&o.expanded===!1&&(i.expandNode(n,!0),n=i.findEle(o.id),o=n.nodeObj),e==="after"&&(t=t.reverse());let r=new Set;for(let a of t){let l=a.nodeObj;if(mn(e,l,o),F(i.nodeData),e==="in"){let c=a.parentElement.parentElement,d=c.parentElement;Mt(i,n,c),r.add(d)}else{oe(a);let c=a.parentElement.parentNode;r.add(c.parentElement),n.parentElement.parentNode.insertAdjacentElement(Et[e],c)}}for(let a of r)a.childElementCount===0&&a.tagName!=="ME-MAIN"&&(a.previousSibling.children[1].remove(),a.remove());i.linkDiv(),i.scrollIntoView(t[t.length-1]);let s=e==="before"?"moveNodeBefore":e==="after"?"moveNodeAfter":"moveNodeIn";i.bus.fire("operation",{name:s,objs:t.map(a=>a.nodeObj),toObj:o})},Cn=function(t,e){re(t,"in",e,this)},Tn=function(t,e){re(t,"before",e,this)},Nn=function(t,e){re(t,"after",e,this)},kn=function(t){let e=t||this.currentNode;e&&(e.nodeObj.dangerouslySetInnerHTML||this.editTopic(e))},_n=function(t,e){t.text.textContent=e,t.nodeObj.topic=e,this.linkDiv()},Ct=Object.freeze(Object.defineProperty({__proto__:null,addChild:yn,beginEdit:kn,copyNode:xn,copyNodes:wn,insertParent:vn,insertSibling:bn,moveDownNode:En,moveNodeAfter:Nn,moveNodeBefore:Tn,moveNodeIn:Cn,moveUpNode:Mn,removeNodes:Sn,reshapeNode:gn,rmSubline:oe,setNodeTopic:_n},Symbol.toStringTag,{value:"Module"}));function Ln(t){return{nodeData:t.isFocusMode?t.nodeDataBackup:t.nodeData,arrows:t.arrows,summaries:t.summaries,direction:t.direction,theme:t.theme,compact:t.compact,meta:t.meta}}var Dn=function(t,e=!1){let n=this.container,i=t.getBoundingClientRect(),o=n.getBoundingClientRect();if(e||i.top>o.bottom-50||i.bottomo.right-50||i.right{if(!(e==="parent"&&typeof n!="string"))return n})},On=function(){let t=Ln(this);return Ve(t)},$n=function(){return JSON.parse(this.getDataString())},Pn=function(){this.editable=!0},Hn=function(){this.editable=!1},Rn=function(t,e={x:0,y:0}){if(tthis.scaleMax&&t>this.scaleVal)return;let n=this.container.getBoundingClientRect(),i=e.x?e.x-n.left-n.width/2:0,o=e.y?e.y-n.top-n.height/2:0,{dx:r,dy:s}=We(this),a=this.map.style.transform,{x:l,y:c}=He(a),d=l-r,p=c-s,m=this.scaleVal,f=(-i+d)*(1-t/m),g=(-o+p)*(1-t/m);this.map.style.transform=`translate3d(${l-f}px, ${c-g}px, 0) scale(${t})`,this.scaleVal=t,this.bus.fire("scale",t)},Bn=function(){let t=this.nodes.offsetHeight/this.container.offsetHeight,e=this.nodes.offsetWidth/this.container.offsetWidth,n=1/Math.max(1,Math.max(t,e));this.scaleVal=n;let{dx:i,dy:o}=We(this,!0);this.map.style.transform=`translate3d(${i}px, ${o}px, 0) scale(${n})`,this.bus.fire("scale",n)},Yn=function(t,e,n=!1){let{map:i,scaleVal:o,bus:r,container:s,nodes:a}=this;if(n&&i.style.transition==="transform 0.3s")return!1;let l=i.style.transform,{x:c,y:d}=He(l),p=s.getBoundingClientRect(),m=a.getBoundingClientRect(),f=(p.left+p.right)/2,g=(p.top+p.bottom)/2;return t>0?t=Math.min(t,Math.max(0,f-m.left)):t<0&&(t=Math.max(t,Math.min(0,f-m.right))),e>0?e=Math.min(e,Math.max(0,g-m.top)):e<0&&(e=Math.max(e,Math.min(0,g-m.bottom))),t===0&&e===0?!1:(c+=t,d+=e,n&&(i.style.transition="transform 0.3s",setTimeout(()=>{i.style.transition="none"},300)),i.style.transform=`translate3d(${c}px, ${d}px, 0) scale(${o})`,r.fire("move",{dx:t,dy:e}),!0)},We=(t,e=!1)=>{let{container:n,map:i,nodes:o}=t,r,s;if(t.alignment==="nodes"||e||t.direction===3)r=(n.offsetWidth-o.offsetWidth)/2,s=(n.offsetHeight-o.offsetHeight)/2,i.style.transformOrigin="50% 50%";else{let a=i.querySelector("me-root"),l=a.offsetTop,c=a.offsetLeft,d=a.offsetWidth,p=a.offsetHeight;r=n.offsetWidth/2-c-d/2,s=n.offsetHeight/2-l-p/2,i.style.transformOrigin=`${c+d/2}px 50%`}return{dx:r,dy:s}},Vn=function(){let{map:t,container:e}=this,{dx:n,dy:i}=We(this);e.scrollTop=0,e.scrollLeft=0,t.style.transform=`translate3d(${n}px, ${i}px, 0) scale(${this.scaleVal})`},Wn=function(t){t(this)},Fn=function(t){t.nodeObj.parent&&(this.clearSelection(),this.tempDirection===null&&(this.tempDirection=this.direction),this.isFocusMode||(this.nodeDataBackup=this.nodeData,this.isFocusMode=!0),this.nodeData=t.nodeObj,this.initRight(),this.toCenter())},qn=function(){this.isFocusMode=!1,this.tempDirection!==null&&(this.nodeData=this.nodeDataBackup,this.direction=this.tempDirection,this.tempDirection=null,this.refresh(),this.toCenter())},Kn=function(){this.direction=0,this.refresh(),this.toCenter(),this.bus.fire("changeDirection",this.direction)},Un=function(){this.direction=1,this.refresh(),this.toCenter(),this.bus.fire("changeDirection",this.direction)},Xn=function(){this.direction=2,this.refresh(),this.toCenter(),this.bus.fire("changeDirection",this.direction)},Gn=function(){this.direction=3,this.refresh(),this.toCenter(),this.bus.fire("changeDirection",this.direction)},Jn=function(t,e){let n=t.nodeObj;typeof e=="boolean"?n.expanded=e:n.expanded!==!1?n.expanded=!1:n.expanded=!0;let i=t.getBoundingClientRect(),o={x:i.left,y:i.top},r=t.parentNode,s=r.children[1];if(s.expanded=n.expanded,s.className=n.expanded?"minus":"",oe(t),n.expanded){let p=this.createChildren(n.children.map(m=>this.createWrapper(m).grp));r.parentNode.appendChild(p)}else r.parentNode.children[1].remove();this.linkDiv(t.closest("me-main > me-wrapper"));let a=t.getBoundingClientRect(),l={x:a.left,y:a.top},c=o.x-l.x,d=o.y-l.y;this.move(c,d),this.bus.fire("expandNode",n)},Zn=function(t,e){let n=t.nodeObj,i=t.getBoundingClientRect(),o={x:i.left,y:i.top};J(n,e??!n.expanded),this.refresh();let r=this.findEle(n.id).getBoundingClientRect(),s={x:r.left,y:r.top},a=o.x-s.x,l=o.y-s.y;this.move(a,l)},Qn=function(t){this.clearSelection(),t&&(t=JSON.parse(JSON.stringify(t)),this.nodeData=t.nodeData,this.arrows=t.arrows||[],this.summaries=t.summaries||[],t.meta&&(this.meta=t.meta)),F(this.nodeData),this.layout(),this.linkDiv()},ei=Object.freeze(Object.defineProperty({__proto__:null,cancelFocus:qn,clearSelection:In,disableEdit:Hn,enableEdit:Pn,expandNode:Jn,expandNodeAll:Zn,focusNode:Fn,getData:$n,getDataString:On,initDown:Gn,initLeft:Kn,initRight:Un,initSide:Xn,install:Wn,move:Yn,refresh:Qn,scale:Rn,scaleFit:Bn,scrollIntoView:Dn,selectNode:An,selectNodes:zn,stringifyData:Ve,toCenter:Vn,unselectNodes:jn},Symbol.toStringTag,{value:"Module"})),it="MIND-ELIXIR-WAIT-COPY",ti=40,ni=10,ii=({deltaMode:t,deltaY:e,viewportHeight:n})=>t===WheelEvent.DOM_DELTA_LINE?e*ti:t===WheelEvent.DOM_DELTA_PAGE?e*n:e,oi=({deltaMode:t,deltaY:e,scaleSensitivity:n,viewportHeight:i})=>{let o=-ii({deltaMode:t,deltaY:e,viewportHeight:i})/ni*n;return Math.max(-n,Math.min(n,o))},Tt=(t,e,n)=>{e!==0&&t.scale(t.scaleVal+e,n)},ri=(t,e)=>{let n=t.map.querySelectorAll(`.${e}>me-wrapper>me-parent>me-tpc`);n.length!==0&&t.selectNode(n[Math.ceil(n.length/2)-1])},Nt=t=>{t.selectNode(t.map.querySelector("me-root>me-tpc"))},kt=function(t,e){let n=e.parentElement.parentElement.parentElement.previousSibling;if(n){let i=n.firstChild;t.selectNode(i)}},_t=function(t,e){let n=e.parentElement.nextSibling;if(n&&n.firstChild){let i=n.firstChild.firstChild.firstChild;t.selectNode(i)}},ot=function(t,e){let n=t.currentNode||t.currentNodes?.[0];if(!n)return;let i=n.nodeObj,o=n.offsetParent.offsetParent.parentElement;i.parent?o.className===e?_t(t,n):i.parent?.parent?kt(t,n):Nt(t):ri(t,e)},he=function(t,e){let n=t.currentNode;if(!n||!n.nodeObj.parent)return;let i=e+"Sibling",o=n.parentElement.parentElement[i];o?t.selectNode(o.firstChild.firstChild):t.selectNode(n)},si=function(t,e){let n=e.nodeObj;n.parent&&(n.parent.parent?kt(t,e):Nt(t))},ai=function(t,e){if(e.nodeObj.parent)_t(t,e);else{let n=t.map.querySelectorAll(".down>me-wrapper>me-parent>me-tpc");if(n.length===0)return;t.selectNode(n[Math.ceil(n.length/2)-1])}},rt=function(t,e,n){let i=e==="in"?t.scaleSensitivity:-t.scaleSensitivity;Tt(t,i,n)},li=(t,e)=>{let n=oi({deltaMode:e.deltaMode,deltaY:e.deltaY,scaleSensitivity:t.scaleSensitivity,viewportHeight:t.container.clientHeight||window.innerHeight});Tt(t,n,{x:e.clientX,y:e.clientY})};function ci(t,e){e=e===!0?{}:e;let n=()=>{t.currentArrow?t.removeArrow():t.currentSummary?t.removeSummary(t.currentSummary.summaryObj.id):t.currentNodes&&t.removeNodes(t.currentNodes)},i=!1,o=null,r=()=>{i=!1,o&&(clearTimeout(o),o=null),t.container.removeEventListener("keydown",s)},s=c=>{if(["Control","Meta","Shift","Alt"].includes(c.key))return;let d=t.nodeData;if(!d.children?.length){r();return}let p=!0;if(c.key==="0")for(let m of d.children)J(m,!1);else if(c.key==="=")for(let m of d.children)J(m,!0);else if(["1","2","3","4","5","6","7","8","9"].includes(c.key))for(let m of d.children)J(m,!0,Number(c.key)-1);else p=!1;p&&(t.refresh(),t.toCenter()),r()},a={Enter:c=>{c.shiftKey?t.insertSibling("before"):c.ctrlKey||c.metaKey?t.insertParent():t.insertSibling("after")},Tab:()=>{t.addChild()},F1:()=>{t.toCenter()},F2:()=>{t.currentSummary?t.editSummary(t.currentSummary):t.currentArrow?t.editArrowLabel(t.currentArrow):t.beginEdit()},ArrowUp:c=>{if(c.altKey)t.moveUpNode();else{if(c.metaKey||c.ctrlKey)return t.initSide();if(t.direction===3){let d=t.currentNode||t.currentNodes?.[0];d&&si(t,d)}else he(t,"previous")}},ArrowDown:c=>{if(c.altKey)t.moveDownNode();else if(t.direction===3){let d=t.currentNode||t.currentNodes?.[0];d&&ai(t,d)}else he(t,"next")},ArrowLeft:c=>{if(c.metaKey||c.ctrlKey)return t.initLeft();t.direction===3?he(t,"previous"):ot(t,z.LHS)},ArrowRight:c=>{if(c.metaKey||c.ctrlKey)return t.initRight();t.direction===3?he(t,"next"):ot(t,z.RHS)},PageUp:()=>t.moveUpNode(),PageDown:()=>{t.moveDownNode()},"=":c=>{(c.metaKey||c.ctrlKey)&&rt(t,"in")},"-":c=>{(c.metaKey||c.ctrlKey)&&rt(t,"out")},0:c=>{if(c.metaKey||c.ctrlKey){if(i)return;t.scale(1)}},k:c=>{(c.metaKey||c.ctrlKey)&&(r(),i=!0,o=window.setTimeout(()=>{o=null,r()},2e3),t.container.addEventListener("keydown",s))},Delete:n,Backspace:n,...e};t.container.onkeydown=c=>{if((c.ctrlKey||c.metaKey)&&["c","v","x"].includes(c.key)||c.preventDefault(),!t.editable)return;let d=a[c.key];d&&d(c)};let l=c=>{if(c.target instanceof HTMLElement&&c.target.id==="input-box"||t.currentNodes.length===0)return!1;if(c.clipboardData){let d=Pe(t.currentNodes).map(m=>m.nodeObj),p=Ve({magic:it,data:d});return c.clipboardData.setData("text/plain",p),c.preventDefault(),!0}return!1};t.container.addEventListener("copy",l),t.container.addEventListener("cut",c=>{l(c)&&n()}),t.container.addEventListener("paste",c=>{let d=c.clipboardData?.getData("text/plain");if(d)try{let p=JSON.parse(d);if(p&&p.magic===it&&Array.isArray(p.data)){let m=p.data,f=m.map(g=>({nodeObj:g}));m.length>0&&t.currentNode&&(t.copyNodes(f,t.currentNode),c.preventDefault());return}}catch{}t.pasteHandler&&t.pasteHandler(c)})}function di(t){let{panHelper:e,container:n}=t,i=null;t.spacePressed=!1;let o={lastTap:0,lastTapTarget:null,DOUBLE_CLICK_THRESHOLD:300,detect(h,v){if(h.button!==0){this.clear();return}let w=new Date().getTime(),E=w-this.lastTap,M=E0&&this.lastTapTarget===h.target;this.lastTap=w,this.lastTapTarget=h.target,M&&v(h)},clear(){this.lastTap=0,this.lastTapTarget=null}},r={Idle:0,Pinch:1,DragWait:2,Drag:3,Pan:4,BoxSelect:5};t.ptState=r.Idle;let s={lastDistance:null,activePointers:new Map,handlePointerDown(h){if(h.pointerType!=="touch")return!1;if(this.activePointers.set(h.pointerId,{x:h.clientX,y:h.clientY}),this.activePointers.size>=2){let[v,w]=Array.from(this.activePointers.values());return this.lastDistance=Qe(v,w),!0}return!1},handlePointerMove(h){if(h.pointerType!=="touch"||!this.activePointers.has(h.pointerId))return!1;if(this.activePointers.set(h.pointerId,{x:h.clientX,y:h.clientY}),this.activePointers.size>=2){let[v,w]=Array.from(this.activePointers.values()),E=Qe(v,w);if(this.lastDistance!==null&&this.lastDistance>0){let M=E/this.lastDistance;t.scale(t.scaleVal*M,{x:(v.x+w.x)/2,y:(v.y+w.y)/2})}return this.lastDistance=E,!0}return!1},handlePointerUp(h){h.pointerType==="touch"&&(this.activePointers.delete(h.pointerId),this.activePointers.size<2&&(this.lastDistance=null))},clear(){this.activePointers.clear(),this.lastDistance=null}},a=Jt(t),l={timer:null,startPos:null,pointerId:null,DURATION:500,MOVE_THRESHOLD:10,clear(){this.timer!==null&&(clearTimeout(this.timer),this.timer=null,this.startPos=null,this.pointerId=null)},start(h,v){this.timer=window.setTimeout(()=>{v(h),this.timer=null,this.startPos=null,this.pointerId=null},this.DURATION),this.startPos={x:h.clientX,y:h.clientY},this.pointerId=h.pointerId},handleMove(h){if(this.timer!==null&&this.startPos!==null&&h.pointerId===this.pointerId){let v=h.clientX-this.startPos.x,w=h.clientY-this.startPos.y;Math.sqrt(v*v+w*w)>this.MOVE_THRESHOLD&&this.clear()}}},c=(h,v)=>{if(h.closest("#input-box"))return!1;let w=h.closest(".svg-label"),E=h.closest(".topiclinks, .summary"),M=w?{type:w.dataset.type,element:document.getElementById(w.dataset.svgId)}:E?{type:E.classList.contains("topiclinks")?"arrow":"summary",element:h.closest("g")}:null;if(!M?.type||!M?.element)return!1;let{type:k,element:C}=M;return t.clearSelection(),k==="arrow"?v?t.editArrowLabel(C):t.selectArrow(C):v?t.editSummary(C):t.selectSummary(C),!0},d=h=>{if(h.pointerType==="mouse"&&h.button!==0)return;if(t.helper1?.moved){t.helper1.clear();return}if(t.helper2?.moved){t.helper2.clear();return}if(e.moved){e.clear();return}if(a?.isDragging)return;let v=h.target;v.tagName==="ME-EPD"&&(h.ctrlKey||h.metaKey?t.expandNodeAll(v.previousSibling):t.expandNode(v.previousSibling))},p=h=>{if(!t.editable)return;let v=h.target;if(ge(v)){t.selectNode(v),t.beginEdit(v);return}c(v,!0)},m=h=>{if(h.pointerType==="touch"&&s.handlePointerDown(h)){t.ptState=r.Pinch,l.clear(),e.clear(),(a.isDragging||a.pointerId!==null)&&nt(t,a);return}if(t.ptState===r.Pinch)return;let v=h.target;if(t.editable&&v.className==="map-container"&&h.button===0&&h.pointerType==="mouse"){t.ptState=r.BoxSelect;return}if(e.handlePointerDown(h),e.mousedown&&(t.ptState=r.Pan),h.button===0||h.pointerType==="touch")if(ge(v)){t.selection?.cancel();let w=t.currentNodes||[];if(h.ctrlKey||h.metaKey||t.mobileMultiSelect?w.includes(v)?i=v:((t.currentArrow||t.currentSummary)&&t.clearSelection(),t.selection?.select(v)):w.includes(v)||t.selectNode(v),!t.editable)return;h.pointerType==="touch"?(t.ptState=r.DragWait,l.start(h,E=>{tt(t,a,E,!0)&&(t.ptState=r.Drag,v.setPointerCapture(E.pointerId))})):tt(t,a,h,!1)&&(t.ptState=r.Drag,v.setPointerCapture(h.pointerId))}else c(v,!1)},f=h=>{switch(t.ptState){case r.Pinch:s.handlePointerMove(h);break;case r.DragWait:l.handleMove(h),l.timer===null&&(t.ptState=r.Pan,e.handlePointerMove(h));break;case r.Drag:Qt(t,a,h);break;case r.Pan:e.handlePointerMove(h);break}},g=h=>{h.preventDefault(),window.removeEventListener("contextmenu",g,!0)},x=h=>{h.pointerType==="touch"&&s.handlePointerUp(h);let v=a.isDragging,w=e.moved;switch(t.ptState){case r.DragWait:l.clear();break;case r.Drag:en(t,a,h);break;case r.Pan:e.handlePointerUp(h),e.moved&&h.button===2&&h.pointerType==="mouse"&&(window.addEventListener("contextmenu",g,{capture:!0,once:!0}),setTimeout(()=>window.removeEventListener("contextmenu",g,!0),300));break}o.detect(h,p),(t.ptState!==r.Pinch||s.activePointers.size<2)&&(t.ptState=r.Idle),i&&(!v&&!w&&t.selection?.deselect(i),i=null)},b=()=>{s.clear(),l.clear(),e.clear(),o.clear(),(a.isDragging||a.pointerId!==null)&&nt(t,a),t.ptState=r.Idle,i=null},u=h=>{h.preventDefault(),h.button===2&&t.editable&&setTimeout(()=>{if(t.panHelper.moved||t.ptState!==r.Idle&&t.ptState!==r.Pan)return;let v=h.target;ge(v)&&!v.classList.contains("selected")&&t.selectNode(v),t.bus.fire("showContextMenu",h)},200)},y=h=>{if(h.ctrlKey||h.metaKey)return h.stopPropagation(),h.preventDefault(),li(t,h);(h.shiftKey?t.move(-h.deltaY,0):t.move(-h.deltaX,-h.deltaY))&&(h.stopPropagation(),h.preventDefault())},S=h=>{h.code==="Space"&&(t.spacePressed=!0,t.container.classList.add("space-pressed"))},T=h=>{h.code==="Space"&&(t.spacePressed=!1,t.container.classList.remove("space-pressed"))};return gt([{dom:n,evt:"pointerdown",func:m},{dom:n,evt:"pointermove",func:f},{dom:n,evt:"pointerup",func:x},{dom:n,evt:"pointercancel",func:b},{dom:n,evt:"click",func:d},{dom:n,evt:"contextmenu",func:u},{dom:n,evt:"wheel",func:typeof t.handleWheel=="function"?t.handleWheel:y},{dom:n,evt:"blur",func:b},{dom:n,evt:"keydown",func:S},{dom:n,evt:"keyup",func:T}])}function pi(){return{handlers:{},addListener:function(t,e){this.handlers[t]===void 0&&(this.handlers[t]=[]),this.handlers[t].push(e)},fire:function(t,...e){if(this.handlers[t]instanceof Array){let n=this.handlers[t];for(let i=0;i{let a=r.d,l=document.createElementNS($,"path"),c={d:a,stroke:i?.stroke||"rgb(227, 125, 116)",fill:"none","stroke-width":String(i?.strokeWidth||"2")};i?.opacity!==void 0&&(c.opacity=String(i.opacity)),_(l,c),s===0&&l.setAttribute("stroke-dasharray",i?.strokeDasharray||"8,2");let d=document.createElementNS($,"path");_(d,{d:a,stroke:"transparent",fill:"none","stroke-width":"15"}),o.appendChild(d),o.appendChild(l),o[r.name]=l}),o},Dt=function(t,e,n){if(!e)return;let i=n.label;e.style.opacity="0";let o=e.cloneNode(!0);t.nodes.appendChild(o),o.id="input-box",o.textContent=i,o.contentEditable="plaintext-only",o.spellcheck=!1,o.style.cssText=` + left:${e.style.left}; + top:${e.style.top}; + max-width: 200px; + `,xt(o),t.scrollIntoView(o),o.addEventListener("keydown",r=>{if(r.stopPropagation(),r.isComposing)return;let s=r.key;if(s==="Enter"||s==="Tab"){if(r.shiftKey)return;r.preventDefault(),o.blur(),t.container.focus()}}),o.addEventListener("blur",()=>{if(!o)return;let r=o.innerText?.trim()||"";r===""?n.label=i:n.label=r,e.style.opacity="1",o.remove(),r!==i&&(t.markdown?e.innerHTML=t.markdown(n.label,n):e.textContent=n.label,Me(e),"parent"in n?t.bus.fire("operation",{name:"finishEditSummary",obj:n}):t.bus.fire("operation",{name:"finishEditArrowLabel",obj:n}))})},mi=function(t){let e=this.map.querySelector("me-root"),n=e.offsetTop,i=e.offsetLeft,o=e.offsetWidth,r=e.offsetHeight,s=this.map.querySelectorAll("me-main > me-wrapper");this.lines.innerHTML="";for(let a=0;a{let w=document.createElement("div");return w.innerText=v,w.className="tips",w},o=(v,w,E)=>{let M=document.createElement("li");return M.id=v,M.innerHTML=`${De(w)}${De(E)}`,M},r=e.locale,s=o("cm-add_child",r.addChild,"Tab"),a=o("cm-add_parent",r.addParent,"Ctrl + Enter"),l=o("cm-add_sibling",r.addSibling,"Enter"),c=o("cm-remove_child",r.removeNode,"Delete"),d=o("cm-fucus",r.focus,""),p=o("cm-unfucus",r.cancelFocus,""),m=o("cm-up",r.moveUp,"PgUp"),f=o("cm-down",r.moveDown,"Pgdn"),g=o("cm-link",r.link,""),x=o("cm-link-bidirectional",r.linkBidirectional,""),b=o("cm-summary",r.summary,""),u=document.createElement("ul");if(u.className="menu-list",u.appendChild(s),u.appendChild(a),u.appendChild(l),u.appendChild(c),e.focus&&(u.appendChild(d),u.appendChild(p)),u.appendChild(m),u.appendChild(f),u.appendChild(b),e.link&&(u.appendChild(g),u.appendChild(x)),e&&e.extend)for(let v=0;v{w.onclick(M)}}let y=document.createElement("div");y.className="context-menu",y.appendChild(u),y.hidden=!0,t.container.append(y);let S=!0,T=v=>{let w=v.target;if(ge(w)){w.parentElement.tagName==="ME-ROOT"?S=!0:S=!1,S?(d.className="disabled",m.className="disabled",f.className="disabled",a.className="disabled",l.className="disabled",c.className="disabled"):(d.className="",m.className="",f.className="",a.className="",l.className="",c.className=""),y.hidden=!1,u.style.top="",u.style.bottom="",u.style.left="",u.style.right="";let E=u.offsetHeight,M=u.offsetWidth,k=u.getBoundingClientRect(),C=v.clientY-k.top,D=v.clientX-k.left;E+C>window.innerHeight?(u.style.top="",u.style.bottom="0px"):(u.style.bottom="",u.style.top=C+15+"px"),M+D>window.innerWidth?(u.style.left="",u.style.right="0px"):(u.style.right="",u.style.left=D+10+"px")}};t.bus.addListener("showContextMenu",T),y.onclick=v=>{v.target===y&&(y.hidden=!0)},s.onclick=()=>{t.addChild(),y.hidden=!0},a.onclick=()=>{t.insertParent(),y.hidden=!0},l.onclick=()=>{S||(t.insertSibling("after"),y.hidden=!0)},c.onclick=()=>{S||(t.removeNodes(t.currentNodes||[]),y.hidden=!0)},d.onclick=()=>{S||(t.focusNode(t.currentNode),y.hidden=!0)},p.onclick=()=>{t.cancelFocus(),y.hidden=!0},m.onclick=()=>{S||(t.moveUpNode(),y.hidden=!0)},f.onclick=()=>{S||(t.moveDownNode(),y.hidden=!0)};let h=v=>{y.hidden=!0;let w=t.currentNode,E=i(r.clickTips);t.container.appendChild(E),t.map.addEventListener("click",M=>{M.preventDefault(),E.remove();let k=M.target;(k.parentElement.tagName==="ME-PARENT"||k.parentElement.tagName==="ME-ROOT")&&t.createArrow(w,k,v)},{once:!0})};return g.onclick=()=>h(),x.onclick=()=>h({bidirectional:!0}),b.onclick=()=>{y.hidden=!0,t.createSummary(),t.unselectNodes(t.currentNodes)},()=>{s.onclick=null,a.onclick=null,l.onclick=null,c.onclick=null,d.onclick=null,p.onclick=null,m.onclick=null,f.onclick=null,g.onclick=null,b.onclick=null,y.onclick=null,t.container.oncontextmenu=null}}var gi=function(t){return["createSummary","removeSummary","finishEditSummary"].includes(t.name)?{type:"summary",value:t.obj.id}:["createArrow","removeArrow","finishEditArrowLabel","reshapeArrow"].includes(t.name)?{type:"arrow",value:t.obj.id}:["removeNodes","copyNodes","moveNodeBefore","moveNodeAfter","moveNodeIn"].includes(t.name)?{type:"nodes",value:t.objs.map(e=>e.id)}:{type:"nodes",value:[t.obj.id]}};function bi(t){let e=[],n=-1,i=t.getData(),o=[];t.undo=function(){if(n>-1){let l=e[n];i=l.prev,t.refresh(l.prev);try{l.currentTarget.type==="nodes"&&(l.operation==="removeNodes"?t.selectNodes(l.currentTarget.value.map(c=>this.findEle(c))):t.selectNodes(l.currentSelected.map(c=>this.findEle(c))))}catch{}finally{n--}}},t.redo=function(){if(nthis.findEle(c))):t.selectNodes(l.currentTarget.value.map(c=>this.findEle(c))))}catch{}}},t.clearHistory=function(){e=[],n=-1,i=t.getData(),t.clearSelection()};let r=function(l){if(l.name==="beginEdit")return;e=e.slice(0,n+1);let c=t.getData(),d={prev:i,operation:l.name,currentSelected:o.map(p=>p.id),currentTarget:gi(l),next:c};e.push(d),i=c,n=e.length-1},s=function(l){if(!t.editable||!l.metaKey&&!l.ctrlKey)return;let c=l.key.toLowerCase();c==="z"?l.shiftKey?t.redo():t.undo():c==="y"&&t.redo()},a=function(){o=t.currentNodes.map(l=>l.nodeObj)};return t.bus.addListener("operation",r),t.bus.addListener("selectNodes",a),t.bus.addListener("unselectNodes",a),t.container.addEventListener("keydown",s),()=>{t.bus.removeListener("operation",r),t.bus.removeListener("selectNodes",a),t.bus.removeListener("unselectNodes",a),t.container.removeEventListener("keydown",s)}}var vi='',yi='',xi='',wi='',Mi='',Ei='',Si='',Ci={side:vi,left:yi,right:xi,full:wi,living:Mi,zoomin:Ei,zoomout:Si},V=(t,e)=>{let n=document.createElement("span");return n.id=t,n.innerHTML=Ci[e],n};function Ti(t){let e=document.createElement("div"),n=V("fullscreen","full"),i=V("toCenter","living"),o=V("zoomout","zoomout"),r=V("zoomin","zoomin");e.appendChild(n),e.appendChild(i),e.appendChild(o),e.appendChild(r),e.className="mind-elixir-toolbar rb";let s=null,a=()=>{let c=t.container.getBoundingClientRect(),d=He(t.map.style.transform),p=c.width/2,m=c.height/2,f=(p-d.x)/t.scaleVal,g=(m-d.y)/t.scaleVal;s={containerRect:c,currentTransform:d,mapCenterX:f,mapCenterY:g}},l=()=>{if(s){let c=t.container.getBoundingClientRect(),d=c.width/2,p=c.height/2,m=d-s.mapCenterX*t.scaleVal,f=p-s.mapCenterY*t.scaleVal,g=m-s.currentTransform.x,x=f-s.currentTransform.y;t.move(g,x)}};return t.el.addEventListener("fullscreenchange",l),n.onclick=()=>{a(),document.fullscreenElement!==t.el?t.el.requestFullscreen():document.exitFullscreen()},i.onclick=()=>{t.toCenter()},o.onclick=()=>{t.scale(t.scaleVal-t.scaleSensitivity)},r.onclick=()=>{t.scale(t.scaleVal+t.scaleSensitivity)},e}function Ni(t){let e=document.createElement("div"),n=V("tbltl","left"),i=V("tbltr","right"),o=V("tblts","side");return e.appendChild(n),e.appendChild(i),e.appendChild(o),e.className="mind-elixir-toolbar lt",n.onclick=()=>{t.initLeft()},i.onclick=()=>{t.initRight()},o.onclick=()=>{t.initSide()},e}function ki(t){t.container.append(Ti(t)),t.container.append(Ni(t))}var ze=class{_listeners=new Map;addEventListener(e,n){let i=this._listeners.get(e)??new Set;return this._listeners.set(e,i),i.add(n),this}removeEventListener(e,n){return this._listeners.get(e)?.delete(n),this}dispatchEvent(e,...n){let i=!0;for(let o of this._listeners.get(e)??[])i=o(...n)!==!1&&i;return i}unbindAllListeners(){this._listeners.clear()}on=this.addEventListener;off=this.removeEventListener;emit=this.dispatchEvent},at=(t,e="px")=>typeof t=="number"?t+e:t,H=({style:t},e,n)=>{if(typeof e=="object")for(let[i,o]of Object.entries(e))o!==void 0&&(t[i]=at(o));else n!==void 0&&(t[e]=at(n))},lt=(t=0,e=0,n=0,i=0)=>{let o={x:t,y:e,width:n,height:i,top:e,left:t,right:t+n,bottom:e+i};return{...o,toJSON:()=>JSON.stringify(o)}},_i=t=>{let e,n=-1,i=!1;return{next:(...o)=>{e=o,i||(i=!0,n=requestAnimationFrame(()=>{t(...e),i=!1}))},cancel:()=>{cancelAnimationFrame(n),i=!1}}},ct=(t,e,n="touch")=>{switch(n){case"center":{let i=e.left+e.width/2,o=e.top+e.height/2;return i>=t.left&&i<=t.right&&o>=t.top&&o<=t.bottom}case"cover":return e.left>=t.left&&e.top>=t.top&&e.right<=t.right&&e.bottom<=t.bottom;case"touch":return t.right>=e.left&&t.left<=e.right&&t.bottom>=e.top&&t.top<=e.bottom}},Li=()=>matchMedia("(hover: none), (pointer: coarse)").matches,Di=()=>"safari"in window,je=t=>Array.isArray(t)?t:[t],zt=t=>(e,n,i,o={})=>{(e instanceof HTMLCollection||e instanceof NodeList)&&(e=Array.from(e)),n=je(n),e=je(e);for(let r of e)if(r)for(let s of n)r[t](s,i,{capture:!1,...o})},U=zt("addEventListener"),P=zt("removeEventListener"),me=t=>{let{clientX:e,clientY:n,target:i}=t.touches?.[0]??t;return{x:e,y:n,target:i}},X=(t,e=document)=>je(t).map(n=>typeof n=="string"?Array.from(e.querySelectorAll(n)):n instanceof Element?n:null).flat().filter(Boolean),Ai=(t,e)=>e.some(n=>typeof n=="number"?t.button===n:typeof n=="object"?n.button!==t.button?!1:n.modifiers.every(i=>{switch(i){case"alt":return t.altKey;case"ctrl":return t.ctrlKey||t.metaKey;case"shift":return t.shiftKey}}):!1),{abs:Y,max:dt,min:pt,ceil:ht}=Math,mt=(t=[])=>({stored:t,selected:[],touched:[],changed:{added:[],removed:[]}}),Ie=class extends ze{static version="mind-elixir-fork";_options;_selection=mt();_area;_clippingElement;_targetElement;_targetBoundary;_targetBoundaryScrolled=!0;_targetRect;_selectables=[];_latestElement;_areaLocation={y1:0,x2:0,y2:0,x1:0};_areaRect=lt();_singleClick=!0;_frame;_scrollAvailable=!0;_scrollingActive=!1;_scrollSpeed={x:0,y:0};_scrollDelta={x:0,y:0};constructor(e){super(),this._options={selectionAreaClass:"selection-area",selectionContainerClass:void 0,selectables:[],document:window.document,startAreas:["html"],boundaries:["html"],container:"body",mindElixirInstance:void 0,...e,behaviour:{overlap:"invert",intersect:"touch",triggers:[0],...e.behaviour,startThreshold:e.behaviour?.startThreshold?typeof e.behaviour.startThreshold=="number"?e.behaviour.startThreshold:{x:10,y:10,...e.behaviour.startThreshold}:{x:10,y:10},scrolling:{speedDivider:10,...e.behaviour?.scrolling,startScrollMargins:{x:0,y:0,...e.behaviour?.scrolling?.startScrollMargins}}},features:{range:!0,touch:!0,deselectOnBlur:!1,...e.features,singleTap:{allow:!0,intersect:"native",...e.features?.singleTap}}};for(let r of Object.getOwnPropertyNames(Object.getPrototypeOf(this)))typeof this[r]=="function"&&(this[r]=this[r].bind(this));let{document:n,selectionAreaClass:i,selectionContainerClass:o}=this._options;this._area=n.createElement("div"),this._clippingElement=n.createElement("div"),this._clippingElement.appendChild(this._area),this._area.classList.add(i),o&&this._clippingElement.classList.add(o),H(this._area,{willChange:"top, left, bottom, right, width, height",top:0,left:0,position:"fixed"}),H(this._clippingElement,{overflow:"hidden",position:"fixed",transform:"translate3d(0, 0, 0)",pointerEvents:"none",zIndex:"1"}),this._frame=_i(r=>{this._recalculateSelectionAreaRect(),this._updateElementSelection(),this._emitEvent("move",r),this._redrawSelectionArea()}),this.enable()}_toggleStartEvents(e=!0){let{document:n}=this._options;(e?U:P)(n,"pointerdown",this._onTapStart)}_onTapStart(e,n=!1){let{x:i,y:o,target:r}=me(e),{document:s,startAreas:a,boundaries:l,behaviour:c,features:d}=this._options,p=r.getBoundingClientRect();if(!Ai(e,c.triggers))return;let m=X(a,s),f=X(l,s);this._targetElement=f.find(u=>ct(u.getBoundingClientRect(),p));let g=e.composedPath(),x=m.find(u=>g.includes(u));if(this._targetBoundary=f.find(u=>g.includes(u)),!this._targetElement||!x||!this._targetBoundary||!n&&this._emitEvent("beforestart",e)===!1)return;this._areaLocation={x1:i,y1:o,x2:0,y2:0};let b=s.scrollingElement??s.body;this._scrollDelta={x:b.scrollLeft,y:b.scrollTop},this._singleClick=!0,this.clearSelection(!1,!0),U(s,["pointermove"],this._delayedTapMove,{passive:!1}),U(s,["pointerup","pointercancel"],this._onTapStop),U(s,"scroll",this._onScroll),d.deselectOnBlur&&(this._targetBoundaryScrolled=!1,U(this._targetBoundary,"scroll",this._onStartAreaScroll))}_onSingleTap(e){let{singleTap:{intersect:n},range:i}=this._options.features,o=me(e),r;if(n==="native")r=o.target;else if(n==="touch"){this.resolveSelectables();let{x:a,y:l}=o;r=this._selectables.find(c=>{let{right:d,left:p,top:m,bottom:f}=c.getBoundingClientRect();return ap&&lm})}if(!r)return;for(this.resolveSelectables();!this._selectables.includes(r);)if(r.parentElement)r=r.parentElement;else{this._targetBoundaryScrolled||this.clearSelection();return}let{stored:s}=this._selection;if(this._emitEvent("start",e),e.shiftKey&&i&&this._latestElement){let a=this._latestElement,[l,c]=a.compareDocumentPosition(r)&4?[r,a]:[a,r],d=[...this._selectables.filter(p=>p.compareDocumentPosition(l)&4&&p.compareDocumentPosition(c)&2),l,c];this.select(d),this._latestElement=a}else s.includes(r)&&(s.length===1||e.ctrlKey||s.every(a=>this._selection.stored.includes(a)))?this.deselect(r):(this.select(r),this._latestElement=r)}_delayedTapMove(e){let{container:n,document:i,behaviour:{startThreshold:o}}=this._options,{x1:r,y1:s}=this._areaLocation,{x:a,y:l}=me(e);if(typeof o=="number"&&Y(a+l-(r+s))>=o||typeof o=="object"&&Y(a-r)>=o.x||Y(l-s)>=o.y){if(P(i,["pointermove"],this._delayedTapMove,{passive:!1}),this._emitEvent("beforedrag",e)===!1){P(i,["pointerup","pointercancel"],this._onTapStop);return}U(i,["pointermove"],this._onTapMove,{passive:!1}),H(this._area,"display","block"),X(n,i)[0].appendChild(this._clippingElement),this.resolveSelectables(),this._singleClick=!1,this._targetRect=this._targetElement.getBoundingClientRect(),this._scrollAvailable=this._targetElement.scrollHeight!==this._targetElement.clientHeight||this._targetElement.scrollWidth!==this._targetElement.clientWidth,this._scrollAvailable&&(this._selectables=this._selectables.filter(c=>this._targetElement.contains(c))),this._setupSelectionArea(),this._emitEvent("start",e),this._onTapMove(e)}this._handleMoveEvent(e)}_setupSelectionArea(){let{_clippingElement:e,_targetElement:n,_area:i}=this,o=this._targetRect=n.getBoundingClientRect();this._scrollAvailable?(H(e,{top:o.top,left:o.left,width:o.width,height:o.height}),H(i,{marginTop:-o.top,marginLeft:-o.left})):(H(e,{top:0,left:0,width:"100%",height:"100%"}),H(i,{marginTop:0,marginLeft:0}))}_onTapMove(e){let{_scrollSpeed:n,_areaLocation:i,_options:o,_frame:r}=this,{speedDivider:s}=o.behaviour.scrolling,{x:a,y:l}=me(e);if(i.x2=a,i.y2=l,this._scrollAvailable&&!this._scrollingActive&&(n.y||n.x)){this._scrollingActive=!0;let c=()=>{if(!n.x&&!n.y){this._scrollingActive=!1;return}let d=this._options.mindElixirInstance;if(d&&d.move){let p=n.x?ht(n.x/s):0,m=n.y?ht(n.y/s):0;(p||m)&&(d.move(-p,-m),i.x1-=p,i.y1-=m)}r.next(e),requestAnimationFrame(c)};requestAnimationFrame(c)}else r.next(e);this._handleMoveEvent(e)}_handleMoveEvent(e){let{features:n}=this._options;(n.touch&&Li()||this._scrollAvailable&&Di())&&e.preventDefault()}_onScroll(){let{_scrollDelta:e,_options:{document:n}}=this,{scrollTop:i,scrollLeft:o}=n.scrollingElement??n.body;this._areaLocation.x1+=e.x-o,this._areaLocation.y1+=e.y-i,e.x=o,e.y=i,this._setupSelectionArea(),this._frame.next(null)}_onStartAreaScroll(){this._targetBoundaryScrolled=!0,P(this._targetElement,"scroll",this._onStartAreaScroll)}_recalculateSelectionAreaRect(){let{_scrollSpeed:e,_areaLocation:n,_targetElement:i,_options:o}=this,r=this._targetRect,{x1:s,y1:a}=n,{x2:l,y2:c}=n,{behaviour:{scrolling:{startScrollMargins:d}}}=o;lr.right-d.x?(e.x=Y(r.left+r.width-l-d.x),l=l>r.right?r.right:l):e.x=0,cr.bottom-d.y?(e.y=Y(r.top+r.height-c-d.y),c=c>r.bottom?r.bottom:c):e.y=0;let p=pt(s,l),m=pt(a,c),f=dt(s,l),g=dt(a,c);this._areaRect=lt(p,m,f-p,g-m)}_redrawSelectionArea(){let{x:e,y:n,width:i,height:o}=this._areaRect,{style:r}=this._area;r.left=`${e}px`,r.top=`${n}px`,r.width=`${i}px`,r.height=`${o}px`}_onTapStop(e,n){let{document:i,features:o}=this._options,{_singleClick:r}=this;P(this._targetElement,"scroll",this._onStartAreaScroll),P(i,["pointermove"],this._delayedTapMove),P(i,["pointermove"],this._onTapMove),P(i,["pointerup","pointercancel"],this._onTapStop),P(i,"scroll",this._onScroll),this._keepSelection(),e&&r&&o.singleTap.allow?this._onSingleTap(e):!r&&!n&&(this._updateElementSelection(),this._emitEvent("stop",e)),this._scrollSpeed.x=0,this._scrollSpeed.y=0,this._clippingElement.remove(),this._frame?.cancel(),H(this._area,"display","none")}_updateElementSelection(){let{_selectables:e,_options:n,_selection:i,_areaRect:o}=this,{stored:r,selected:s,touched:a}=i,{intersect:l,overlap:c}=n.behaviour,d=c==="invert",p=[],m=[],f=[];for(let x=0;x!s.includes(x)));let g=c==="keep";for(let x=0;x!s.includes(l));switch(e.behaviour.overlap){case"drop":{n.stored=[...a,...s.filter(l=>!r.includes(l))];break}case"invert":{n.stored=[...a,...s.filter(l=>!o.removed.includes(l))];break}case"keep":{n.stored=[...s,...i.filter(l=>!s.includes(l))];break}}}trigger(e,n=!0){this._onTapStart(e,n)}resolveSelectables(){this._selectables=X(this._options.selectables,this._options.document)}clearSelection(e=!0,n=!1){let{selected:i,stored:o,changed:r}=this._selection;r.added=[],r.removed.push(...i,...e?o:[]),n||(this._emitEvent("move",null),this._emitEvent("stop",null)),this._selection=mt(e?[]:o)}getSelection(){return this._selection.stored}getSelectionArea(){return this._area}getSelectables(){return this._selectables}setAreaLocation(e){Object.assign(this._areaLocation,e),this._redrawSelectionArea()}getAreaLocation(){return this._areaLocation}cancel(e=!1){this._onTapStop(null,!e)}destroy(){this.cancel(),this.disable(),this._clippingElement.remove(),super.unbindAllListeners()}enable=this._toggleStartEvents;disable=this._toggleStartEvents.bind(this,!1);select(e,n=!1){let{changed:i,selected:o,stored:r}=this._selection,s=X(e,this._options.document).filter(a=>!o.includes(a)&&!r.includes(a));return r.push(...s),o.push(...s),i.added.push(...s),i.removed=[],this._latestElement=void 0,n||(this._emitEvent("move",null),this._emitEvent("stop",null)),s}deselect(e,n=!1){let{selected:i,stored:o,changed:r}=this._selection,s=X(e,this._options.document).filter(a=>i.includes(a)||o.includes(a));this._selection.stored=o.filter(a=>!s.includes(a)),this._selection.selected=i.filter(a=>!s.includes(a)),this._selection.changed.added=[],this._selection.changed.removed.push(...s.filter(a=>!r.removed.includes(a))),this._latestElement=void 0,n||(this._emitEvent("move",null),this._emitEvent("stop",null))}};function zi(t){let e=t.mouseSelectionButton===2?[2]:[0],n=new Ie({selectables:[".map-container me-tpc"],boundaries:[t.container],container:t.selectionContainer,mindElixirInstance:t,features:{touch:!1,singleTap:{allow:!1}},behaviour:{triggers:e,scrolling:{speedDivider:10,startScrollMargins:{x:50,y:50}}}}).on("beforestart",({event:i})=>{if(!t.editable||t.spacePressed||t.ptState!==5)return!1;let o=i.target;if(o.id==="input-box"||o.className==="circle"||o.className!=="map-container")return!1;!i.ctrlKey&&!i.metaKey&&t.clearSelection();let r=n.getSelectionArea();return r.style.background="#4f90f22d",r.style.border="1px solid #4f90f2",r.style.borderRadius="3px",r.parentElement&&(r.parentElement.style.zIndex="9999"),!0}).on("move",({store:{changed:{added:i,removed:o}}})=>{if(i.length>0||o.length>0,i.length>0){let r=i.filter(s=>!t.currentNodes?.includes(s));if(r.length>0){for(let s of r)s.className="selected";t.currentNodes=[...t.currentNodes||[],...r],t.bus.fire("selectNodes",r.map(s=>s.nodeObj))}}if(o.length>0){let r=o.filter(s=>t.currentNodes?.includes(s));if(r.length>0){for(let s of r)s.classList.remove("selected");t.currentNodes=(t.currentNodes||[]).filter(s=>!r.includes(s)),t.bus.fire("unselectNodes",r.map(s=>s.nodeObj))}}});t.selection=n}var jt=function(t,e,n,i,o=8){if(t===n)return`M ${t} ${e} V ${i}`;let r=(e+i)/2,s=n>t?1:-1,a=Math.min(o,Math.abs(n-t)/2,Math.abs(r-e),Math.abs(i-r));return`M ${t} ${e} V ${r-a} Q ${t} ${r} ${t+s*a} ${r} H ${n-s*a} Q ${n} ${r} ${n} ${r+a} V ${i}`};function It({pT:t,pL:e,pW:n,pH:i,cT:o,cL:r,cW:s,cH:a,direction:l,containerHeight:c,containerWidth:d}){if(l===z.DOWN){let b=e+n/2,u=r+s/2,y=t+i;return jt(b,y,u,o)}let p=e+n/2,m=t+i/2,f;l===z.LHS?f=r+s:f=r;let g=o+a/2,x=(1-Math.abs(g-m)/c)*.25*(n/2);return l===z.LHS?p=p-n/10-x:p=p+n/10+x,`M ${p} ${m} Q ${p} ${g} ${f} ${g}`}function Ot({pT:t,pL:e,pW:n,pH:i,cT:o,cL:r,cW:s,cH:a,direction:l,isFirst:c}){if(l===z.DOWN){let y=e+n/2,S=t+i,T=r+s/2;return jt(y,S,T,o)}let d=parseInt(this.container.style.getPropertyValue("--node-gap-x")),p=0,m=0;c?p=t+i/2:p=t+i;let f=o+a,g=0,x=0,b=0,u=Math.abs(p-f)/300*d;return l===z.LHS?(b=e,g=b+d,x=b-d,m=r+d,`M ${g} ${p} C ${b} ${p} ${b+u} ${f} ${x} ${f} H ${m}`):(b=e+n,g=b-d,x=b+d,m=r+s-d,`M ${g} ${p} C ${b} ${p} ${b-u} ${f} ${x} ${f} H ${m}`)}var ji=function(t,e=!0){this.theme=t,this.generateMainBranch=this.theme.generateMainBranch||It,this.generateSubBranch=this.theme.generateSubBranch||Ot;let n={...(this.theme.type==="dark"?Le:_e).cssVar,...this.theme.cssVar};this.compact&&(n["--node-gap-x"]="15px",n["--node-gap-y"]="2px",n["--main-gap-x"]="30px",n["--main-gap-y"]="6px");let i=Object.keys(n);for(let o=0;o0&&m[0].setAttribute("d",d);let f=ye(r,s,a,l);if(f){let u=`M ${f.x1} ${f.y1} L ${a} ${l} L ${f.x2} ${f.y2}`;t.arrow1.setAttribute("d",u),m.length>1&&m[1].setAttribute("d",u),t.arrow1.setAttribute("stroke",p.stroke||"rgb(227, 125, 116)"),t.arrow1.setAttribute("stroke-width",String(p.strokeWidth||"2")),p.opacity!==void 0&&p.opacity!==null&&p.opacity!==""?t.arrow1.setAttribute("opacity",String(p.opacity)):t.arrow1.removeAttribute("opacity")}if(c.bidirectional){let u=ye(i,o,e,n);if(u){let y=`M ${u.x1} ${u.y1} L ${e} ${n} L ${u.x2} ${u.y2}`;t.arrow2.setAttribute("d",y),m.length>2&&m[2].setAttribute("d",y)}}else t.arrow2.setAttribute("d",""),m.length>2&&m[2].setAttribute("d","");t.arrow2.setAttribute("stroke",p.stroke||"rgb(227, 125, 116)"),t.arrow2.setAttribute("stroke-width",String(p.strokeWidth||"2")),p.opacity!==void 0&&p.opacity!==null&&p.opacity!==""?t.arrow2.setAttribute("opacity",String(p.opacity)):t.arrow2.removeAttribute("opacity");let{x:g,y:x}=Pt(e,n,i,o,r,s,a,l);t.labelEl&&$i(t.labelEl,g,x);let b=t.labelEl;b&&(b.style.color=p.labelColor||"rgb(235, 95, 82)"),Wi(t)}function Q(t,e,n){let{offsetLeft:i,offsetTop:o}=O(t.nodes,e),r=e.offsetWidth,s=e.offsetHeight,a=i+r/2,l=o+s/2,c=a+n.x,d=l+n.y;return{w:r,h:s,cx:a,cy:l,ctrlX:c,ctrlY:d}}function R(t){let e=t.w/2,n=t.h/2,i=t.ctrlX-t.cx,o=t.ctrlY-t.cy,r=Math.hypot(i,o);if(r===0||e===0&&n===0)return{x:t.cx,y:t.cy};let s=i/r,a=o/r,l=Math.min(e/Math.abs(s),n/Math.abs(a));return{x:t.cx+s*l,y:t.cy+a*l}}var Ht=function(t,e,n){let i=O(t.nodes,e),o=O(t.nodes,n),r=i.offsetLeft+e.offsetWidth/2,s=i.offsetTop+e.offsetHeight/2,a=o.offsetLeft+n.offsetWidth/2,l=o.offsetTop+n.offsetHeight/2,c=a-r,d=l-s,p=Math.sqrt(c*c+d*d),m=Math.max(50,Math.min(200,p*.3)),f=Math.abs(c),g=Math.abs(d),x,b;if(p<150){let u=e.closest("me-main"),y=u?u.className==="lhs"?-1:1:c>0?-1:1;x={x:200*y,y:0},b={x:200*y,y:0}}else if(f>g*1.5){let u=c>0?e.offsetWidth/2:-e.offsetWidth/2,y=c>0?-n.offsetWidth/2:n.offsetWidth/2;x={x:u+(c>0?m:-m),y:0},b={x:y+(c>0?-m:m),y:0}}else if(g>f*1.5){let u=d>0?e.offsetHeight/2:-e.offsetHeight/2,y=d>0?-n.offsetHeight/2:n.offsetHeight/2;x={x:0,y:u+(d>0?m:-m)},b={x:0,y:y+(d>0?-m:m)}}else{let u=Math.atan2(d,c),y=e.offsetWidth/2*Math.cos(u),S=e.offsetHeight/2*Math.sin(u),T=-(n.offsetWidth/2)*Math.cos(u),h=-(n.offsetHeight/2)*Math.sin(u),v=m*.7*(c>0?1:-1),w=m*.7*(d>0?1:-1);x={x:y+v,y:S+w},b={x:T-v,y:h-w}}return{delta1:{x:Math.round(x.x),y:Math.round(x.y)},delta2:{x:Math.round(b.x),y:Math.round(b.y)}}},Fe=function(t,e,n,i,o){if(!e||!n)return;if(!i.delta1||!i.delta2){let M=Ht(t,e,n);i.delta1=M.delta1,i.delta2=M.delta2}let r=Q(t,e,i.delta1),s=Q(t,n,i.delta2),{x:a,y:l}=R(r),{ctrlX:c,ctrlY:d}=r,{ctrlX:p,ctrlY:m}=s,{x:f,y:g}=R(s),x=ye(p,m,f,g);if(!x)return;let b=`M ${x.x1} ${x.y1} L ${f} ${g} L ${x.x2} ${x.y2}`,u="";if(i.bidirectional){let M=ye(c,d,a,l);if(!M)return;u=`M ${M.x1} ${M.y1} L ${a} ${l} L ${M.x2} ${M.y2}`}let y=hi(`M ${a} ${l} C ${c} ${d} ${p} ${m} ${f} ${g}`,b,u,i.style),{x:S,y:T}=Pt(a,l,c,d,p,m,f,g),h=i.style?.labelColor||"rgb(235, 95, 82)",v="a-"+i.id;y.id=v;let w=t.markdown?t.markdown(i.label,i):i.label,E=be(w,S,T,{anchor:"middle",color:h,dataType:"arrow",svgId:v});y.labelEl=E,y.arrowObj=i,y.dataset.linkid=i.id,t.labelContainer.appendChild(E),t.arrowSvg.appendChild(y),Me(E),o||(t.arrows.push(i),t.currentArrow=y,Bt(t,i,r,s))},Pi=function(t,e,n={}){let i={id:q(),label:"Custom Link",from:t.nodeObj.id,to:e.nodeObj.id,...n};Fe(this,t,e,i),this.bus.fire("operation",{name:"createArrow",obj:i})},Hi=function(t){Ee(this);let e={...t,id:q()};Fe(this,this.findEle(e.from),this.findEle(e.to),e),this.bus.fire("operation",{name:"createArrow",obj:e})},Ri=function(t){let e;if(t?e=t:e=this.currentArrow,!e)return;Ee(this);let n=e.arrowObj.id;this.arrows=this.arrows.filter(i=>i.id!==n),e.labelEl?.remove(),e.remove(),this.bus.fire("operation",{name:"removeArrow",obj:{id:n}})},Bi=function(t){this.currentArrow=t;let e=t.arrowObj,n=this.findEle(e.from),i=this.findEle(e.to),o=Q(this,n,e.delta1),r=Q(this,i,e.delta2);this.editable?Bt(this,e,o,r):Rt(t,$t),this.bus.fire("selectArrow",e)},Yi=function(){Ee(this),this.currentArrow=null,this.bus.fire("unselectArrow")},Ne=function(t,e){let n=document.createElementNS($,"path");return _(n,{d:t,stroke:e,fill:"none","stroke-width":"6","stroke-linecap":"round","stroke-linejoin":"round"}),n},Rt=function(t,e){let n=document.createElementNS($,"g");n.setAttribute("class","arrow-highlight"),n.setAttribute("opacity","0.45");let i=Ne(t.line.getAttribute("d"),e);n.appendChild(i);let o=Ne(t.arrow1.getAttribute("d"),e);if(n.appendChild(o),t.arrow2.getAttribute("d")){let r=Ne(t.arrow2.getAttribute("d"),e);n.appendChild(r)}t.insertBefore(n,t.firstChild)},Vi=function(t){let e=t.querySelector(".arrow-highlight");e&&e.remove()},Wi=function(t){let e=t.querySelector(".arrow-highlight");if(!e)return;let n=e.querySelectorAll("path");n.length>=1&&n[0].setAttribute("d",t.line.getAttribute("d")),n.length>=2&&n[1].setAttribute("d",t.arrow1.getAttribute("d")),n.length>=3&&t.arrow2.getAttribute("d")&&n[2].setAttribute("d",t.arrow2.getAttribute("d"))},Ee=function(t){t.helper1?.destroy(),t.helper2?.destroy(),t.linkController.style.display="none",t.P2.style.display="none",t.P3.style.display="none",t.currentArrow&&Vi(t.currentArrow)},Bt=function(t,e,n,i){let{linkController:o,P2:r,P3:s,line1:a,line2:l,nodes:c,map:d,currentArrow:p,bus:m}=t;if(!p)return;o.style.display="initial",r.style.display="initial",s.style.display="initial",c.appendChild(o),c.appendChild(r),c.appendChild(s),Rt(p,$t);let{x:f,y:g}=R(n),{ctrlX:x,ctrlY:b}=n,{ctrlX:u,ctrlY:y}=i,{x:S,y:T}=R(i);r.style.cssText=`top:${b}px;left:${x}px;`,s.style.cssText=`top:${y}px;left:${u}px;`,G(a,f,g,x,b),G(l,u,y,S,T),t.helper1=ut.create(r),t.helper2=ut.create(s);let h=Z(e),v=()=>{m.fire("operation",{name:"reshapeArrow",obj:e,origin:h}),h=Z(e)};t.helper1.init(d,(w,E)=>{x=x+w/t.scaleVal,b=b+E/t.scaleVal;let M=R({...n,ctrlX:x,ctrlY:b});f=M.x,g=M.y,r.style.top=b+"px",r.style.left=x+"px",Oe(p,f,g,x,b,u,y,S,T,e),G(a,f,g,x,b),e.delta1.x=Math.round(x-n.cx),e.delta1.y=Math.round(b-n.cy),m.fire("updateArrowDelta",e)},v),t.helper2.init(d,(w,E)=>{u=u+w/t.scaleVal,y=y+E/t.scaleVal;let M=R({...i,ctrlX:u,ctrlY:y});S=M.x,T=M.y,s.style.top=y+"px",s.style.left=u+"px",Oe(p,f,g,x,b,u,y,S,T,e),G(l,u,y,S,T),e.delta2.x=Math.round(u-i.cx),e.delta2.y=Math.round(y-i.cy),m.fire("updateArrowDelta",e)},v)};function Fi(){this.arrowSvg.innerHTML="",this.labelContainer.querySelectorAll('.svg-label[data-type="arrow"]').forEach(t=>t.remove());for(let t=0;tve(t.from,this.nodeData)&&ve(t.to,this.nodeData))}var Ui=function(t,e){let n=Z(t);n.style&&e.style&&(e.style=Object.assign({},n.style,e.style)),Object.assign(t,e);let i=this.arrowSvg.querySelector(`g[data-linkid="${t.id}"]`);if(i){if(e.label!==void 0&&i.labelEl){let s=this.markdown?this.markdown(t.label,t):t.label;i.labelEl.innerHTML=s}let o=this.findEle(t.from),r=this.findEle(t.to);if(o&&r){if(!t.delta1||!t.delta2){let b=Ht(this,o,r);t.delta1=t.delta1||b.delta1,t.delta2=t.delta2||b.delta2}let s=Q(this,o,t.delta1),a=Q(this,r,t.delta2),{x:l,y:c}=R(s),{ctrlX:d,ctrlY:p}=s,{ctrlX:m,ctrlY:f}=a,{x:g,y:x}=R(a);Oe(i,l,c,d,p,m,f,g,x,t),this.currentArrow?.arrowObj?.id===t.id&&(this.P2.style.cssText=`top:${p}px;left:${d}px;`,this.P3.style.cssText=`top:${f}px;left:${m}px;`,G(this.line1,l,c,d,p),G(this.line2,m,f,g,x))}}this.bus.fire("operation",{name:"reshapeArrow",obj:t,origin:n})},Xi=Object.freeze(Object.defineProperty({__proto__:null,createArrow:Pi,createArrowFrom:Hi,editArrowLabel:qi,removeArrow:Ri,renderArrow:Fi,reshapeArrow:Ui,selectArrow:Bi,tidyArrow:Ki,unselectArrow:Yi},Symbol.toStringTag,{value:"Module"})),Gi=function(t){if(t.length===0)throw new Error("No selected node.");if(t.length===1){let l=t[0].nodeObj,c=t[0].nodeObj.parent;if(!c)throw new Error("Can not select root node.");let d=c.children.findIndex(p=>l===p);return{parent:c.id,start:d,end:d}}let e=0,n=t.map(l=>{let c=l.nodeObj,d=[];for(;c.parent;){let p=c.parent,m=p.children?.indexOf(c);c=p,d.unshift({node:c,index:m})}return d.length>e&&(e=d.length),d}),i=0;e:for(;il[i-1].index).sort((l,c)=>l-c),r=o[0]||0,s=o[o.length-1]||0,a=n[0][i-1].node;if(!a.parent)throw new Error("Please select nodes in the same main topic.");return{parent:a.id,start:r,end:s}},Ji=function(t){let e=document.createElementNS($,"g");return e.setAttribute("id",t),e},ke=function(t,e){let n=document.createElementNS($,"path");return _(n,{d:t,stroke:e||"#666",fill:"none","stroke-linecap":"round","stroke-width":"2"}),n},Zi=t=>t.parentElement.parentElement,Yt=function(t,e){let n=t.summaries.findIndex(i=>i.id===e);return n===-1?!1:(t.summaries.splice(n,1),t.nodes.querySelector("#s-"+e)?.remove(),t.nodes.querySelector("#label-s-"+e)?.remove(),!0)},Qi=function(t,{parent:e,start:n}){let i=t.findEle(e),o=i.nodeObj,r;return o.parent?r=i.closest("me-main").className:r=t.findEle(o.children[n].id).closest("me-main").className,r},qe=function(t,e){let{id:n,label:i,parent:o,start:r,end:s,style:a}=e,{nodes:l,theme:c,summarySvg:d}=t,p=t.findEle(o).nodeObj,m=Qi(t,e),f=1/0,g=0,x=0,b=0,u=0,y=1/0,S=0;for(let C=r;C<=s;C++){let D=p.children?.[C];if(!D)return null;let A=Zi(t.findEle(D.id)),{offsetLeft:I,offsetTop:se}=O(l,A),ae=r===s?10:20;C===r&&(x=se+ae),C===s&&(b=se+A.offsetHeight-ae),C===r&&(y=I+ae),C===s&&(S=I+A.offsetWidth-ae),se+A.offsetHeight>u&&(u=se+A.offsetHeight),Ig&&(g=A.offsetWidth+I)}let T,h,v=a?.stroke||c.cssVar["--color"],w=a?.labelColor||c.cssVar["--color"],E="s-"+n,M=t.markdown?t.markdown(i,e):i;if(m===z.DOWN){let C=u+10,D=(y+S)/2;T=ke(`M ${y} ${C-10} c 0 5 5 10 10 10 L ${S-10} ${C} c 5 0 10 -5 10 -10 M ${D} ${C} v 10`,v),h=be(M,D,C+20,{anchor:"middle",color:w,dataType:"summary",svgId:E})}else{let C=p.parent?10:0,D=x+C,A=b+C,I=(D+A)/2;m===z.LHS?(T=ke(`M ${f+10} ${D} c -5 0 -10 5 -10 10 L ${f} ${A-10} c 0 5 5 10 10 10 M ${f} ${I} h -10`,v),h=be(M,f-20,I,{anchor:"end",color:w,dataType:"summary",svgId:E})):(T=ke(`M ${g-10} ${D} c 5 0 10 5 10 10 L ${g} ${A-10} c 0 5 -5 10 -10 10 M ${g} ${I} h 10`,v),h=be(M,g+20,I,{anchor:"start",color:w,dataType:"summary",svgId:E}))}let k=Ji(E);return k.appendChild(T),t.labelContainer.appendChild(h),Me(h),k.summaryObj=e,k.labelEl=h,d.appendChild(k),k},eo=function(t={}){if(!this.currentNodes)return;let{currentNodes:e,summaries:n,bus:i}=this,{parent:o,start:r,end:s}=Gi(e),a={id:q(),parent:o,start:r,end:s,label:"summary",style:t.style},l=qe(this,a);n.push(a),this.editSummary(l),i.fire("operation",{name:"createSummary",obj:a})},to=function(t){let e=q(),n={...t,id:e};qe(this,n),this.summaries.push(n),this.bus.fire("operation",{name:"createSummary",obj:n})},no=function(t){Yt(this,t)&&this.bus.fire("operation",{name:"removeSummary",obj:{id:t}})},io=function(t){let e=t.labelEl;e&&e.classList.add("selected"),this.currentSummary=t,this.bus.fire("selectSummary",t.summaryObj)},oo=function(){this.currentSummary?.labelEl?.classList.remove("selected"),this.currentSummary=null,this.bus.fire("unselectSummary")},ro=function(){this.summarySvg.innerHTML="";let t=[];this.summaries.forEach(e=>{try{qe(this,e)===null&&t.push(e.id)}catch{}}),t.forEach(e=>Yt(this,e)),this.nodes.insertAdjacentElement("beforeend",this.summarySvg)},so=function(t){t&&t.labelEl&&Dt(this,t.labelEl,t.summaryObj)},ao=Object.freeze(Object.defineProperty({__proto__:null,createSummary:eo,createSummaryFrom:to,editSummary:so,removeSummary:no,renderSummary:ro,selectSummary:io,unselectSummary:oo},Symbol.toStringTag,{value:"Module"})),j="http://www.w3.org/2000/svg";function lo(t,e){let n=document.createElementNS(j,"svg");return _(n,{version:"1.1",xmlns:j,height:t,width:e}),n}function co(t,e){return(parseInt(t)-parseInt(e))/2}function po(t,e,n,i){let o=document.createElementNS(j,"g"),r="";return t.text?r=t.text.textContent:r=t.childNodes[0].textContent,r.split(` +`).forEach((s,a)=>{let l=document.createElementNS(j,"text");_(l,{x:n+parseInt(e.paddingLeft)+"",y:i+parseInt(e.paddingTop)+co(e.lineHeight,e.fontSize)*(a+1)+parseFloat(e.fontSize)*(a+1)+"","text-anchor":"start","font-family":e.fontFamily,"font-size":`${e.fontSize}`,"font-weight":`${e.fontWeight}`,fill:`${e.color}`}),l.innerHTML=s,o.appendChild(l)}),o}function ho(t,e,n,i){let o="";t.nodeObj?.dangerouslySetInnerHTML?o=t.nodeObj.dangerouslySetInnerHTML:t.text?o=t.text.textContent:o=t.childNodes[0].textContent;let r=document.createElementNS(j,"foreignObject");_(r,{x:n+parseInt(e.paddingLeft)+"",y:i+parseInt(e.paddingTop)+"",width:e.width,height:e.height});let s=document.createElement("div");return _(s,{xmlns:"http://www.w3.org/1999/xhtml",style:`font-family: ${e.fontFamily}; font-size: ${e.fontSize}; font-weight: ${e.fontWeight}; color: ${e.color}; white-space: pre-wrap;`}),s.innerHTML=o,r.appendChild(s),r}function mo(t,e){let n=getComputedStyle(e),{offsetLeft:i,offsetTop:o}=O(t.nodes,e),r=document.createElementNS(j,"rect");return _(r,{x:i+"",y:o+"",rx:n.borderRadius,ry:n.borderRadius,width:n.width,height:n.height,fill:n.backgroundColor,stroke:n.borderColor,"stroke-width":n.borderWidth}),r}function ue(t,e,n=!1){let i=getComputedStyle(e),{offsetLeft:o,offsetTop:r}=O(t.nodes,e),s=document.createElementNS(j,"rect");_(s,{x:o+"",y:r+"",rx:i.borderRadius,ry:i.borderRadius,width:i.width,height:i.height,fill:i.backgroundColor,stroke:i.borderColor,"stroke-width":i.borderWidth});let a=document.createElementNS(j,"g");a.appendChild(s);let l;return n?l=ho(e,i,o,r):l=po(e,i,o,r),a.appendChild(l),a}function uo(t,e){let n=getComputedStyle(e),{offsetLeft:i,offsetTop:o}=O(t.nodes,e),r=document.createElementNS(j,"a"),s=document.createElementNS(j,"text");return _(s,{x:i+"",y:o+parseInt(n.fontSize)+"","text-anchor":"start","font-family":n.fontFamily,"font-size":`${n.fontSize}`,"font-weight":`${n.fontWeight}`,fill:`${n.color}`}),s.innerHTML=e.textContent,r.appendChild(s),r.setAttribute("href",e.href),r}function fo(t,e){let n=getComputedStyle(e),{offsetLeft:i,offsetTop:o}=O(t.nodes,e),r=document.createElementNS(j,"image");return _(r,{x:i+"",y:o+"",width:n.width+"",height:n.height+"",href:e.src}),r}var fe=100,go='',bo=(t,e=!1)=>{let n=t.nodes,i=n.offsetHeight+fe*2,o=n.offsetWidth+fe*2,r=lo(i+"px",o+"px"),s=document.createElementNS(j,"svg"),a=document.createElementNS(j,"rect");_(a,{x:"0",y:"0",width:`${o}`,height:`${i}`,fill:t.theme.cssVar["--bgcolor"]}),r.appendChild(a),n.querySelectorAll(".subLines").forEach(p=>{let m=p.cloneNode(!0),{offsetLeft:f,offsetTop:g}=O(n,p.parentElement);m.setAttribute("x",`${f}`),m.setAttribute("y",`${g}`),s.appendChild(m)});let l=n.querySelector(".lines")?.cloneNode(!0);l&&s.appendChild(l);let c=n.querySelector(".topiclinks")?.cloneNode(!0);c&&s.appendChild(c);let d=n.querySelector(".summary")?.cloneNode(!0);return d&&s.appendChild(d),n.querySelectorAll("me-tpc").forEach(p=>{p.nodeObj.dangerouslySetInnerHTML?s.appendChild(ue(t,p,!e)):(s.appendChild(mo(t,p)),s.appendChild(ue(t,p.text,!e)))}),n.querySelectorAll(".tags > span").forEach(p=>{s.appendChild(ue(t,p))}),n.querySelectorAll(".icons > span").forEach(p=>{s.appendChild(ue(t,p))}),n.querySelectorAll(".hyper-link").forEach(p=>{s.appendChild(uo(t,p))}),n.querySelectorAll("img").forEach(p=>{s.appendChild(fo(t,p))}),_(s,{x:fe+"",y:fe+"",overflow:"visible"}),r.appendChild(s),r},vo=(t,e)=>(e&&t.insertAdjacentHTML("afterbegin",""),go+t.outerHTML);function yo(t){return new Promise((e,n)=>{let i=new FileReader;i.onload=o=>{e(o.target.result)},i.onerror=o=>{n(o)},i.readAsDataURL(t)})}var xo=function(t=!1,e){let n=bo(this,t),i=vo(n,e);return new Blob([i],{type:"image/svg+xml"})},wo=async function(t=!1,e){let n=this.exportSvg(t,e),i=await yo(n);return new Promise((o,r)=>{let s=new Image;s.setAttribute("crossOrigin","anonymous"),s.onload=()=>{let a=document.createElement("canvas");a.width=s.width,a.height=s.height,a.getContext("2d").drawImage(s,0,0),a.toBlob(o,"image/png",1)},s.src=i,s.onerror=r})},Mo=Object.freeze(Object.defineProperty({__proto__:null,exportPng:wo,exportSvg:xo},Symbol.toStringTag,{value:"Module"}));function Eo(t,e){return async function(...n){let i=this.before[e];i&&!await i.apply(this,n)||t.apply(this,n)}}var ft=Object.keys(Ct),Vt={};for(let t=0;tt()),this.el&&(this.el.innerHTML=""),this.el=void 0,this.nodeData=void 0,this.arrows=void 0,this.summaries=void 0,this.currentArrow=void 0,this.currentNodes=void 0,this.currentSummary=void 0,this.theme=void 0,this.direction=void 0,this.bus=void 0,this.container=void 0,this.map=void 0,this.lines=void 0,this.linkController=void 0,this.arrowSvg=void 0,this.P2=void 0,this.P3=void 0,this.line1=void 0,this.line2=void 0,this.nodes=void 0,this.selection?.destroy(),this.selection=void 0},enableMobileMultiSelect(t){this.mobileMultiSelect=t}},Co="5.15.1";function To(t){return{x:0,y:0,moved:!1,mousedown:!1,handlePointerDown(e){this.moved=!1;let n=e.target,i=t.mouseSelectionButton===0?2:0,o=t.spacePressed&&e.button===0&&e.pointerType==="mouse",r=!t.editable||e.button===i&&e.pointerType==="mouse"||e.pointerType==="touch";!o&&!r||(this.x=e.clientX,this.y=e.clientY,n.className!=="circle"&&n.contentEditable!=="plaintext-only"&&(this.mousedown=!0,n.setPointerCapture(e.pointerId)))},handlePointerMove(e){if(!this.mousedown||e.target.contentEditable==="plaintext-only"&&!t.spacePressed)return!1;let n=e.clientX-this.x,i=e.clientY-this.y;return this.x=e.clientX,this.y=e.clientY,this.moved=!0,t.move(n,i),!0},handlePointerUp(e){if(!this.mousedown)return;let n=e.target;n.hasPointerCapture&&n.hasPointerCapture(e.pointerId)&&n.releasePointerCapture(e.pointerId),this.mousedown=!1},clear(){this.mousedown=!1,this.moved=!1}}}var W=class{static LEFT=0;static RIGHT=1;static SIDE=2;static DOWN=3;static THEME=_e;static DARK_THEME=Le;static version=Co;static E=yt;static new=e=>({nodeData:{id:q(),topic:e||"new topic",children:[]}});get currentNode(){return this.currentNodes[this.currentNodes.length-1]}constructor({el:e,direction:n,editable:i,contextMenu:o,toolBar:r,keypress:s,mouseSelectionButton:a,selectionContainer:l,before:c,newTopicName:d,allowUndo:p,generateMainBranch:m,generateSubBranch:f,overflowHidden:g,compact:x,theme:b,alignment:u,scaleSensitivity:y,scaleMax:S,scaleMin:T,handleWheel:h,markdown:v,imageProxy:w,pasteHandler:E,mobileMultiSelect:M}){let k=null,C=Object.prototype.toString.call(e);if(C==="[object HTMLDivElement]"?k=e:C==="[object String]"&&(k=document.querySelector(e)),!k)throw new Error("MindElixir: el is not a valid element");k.style.position="relative",k.innerHTML="",this.el=k,this.disposable=[],this.before=c||{},this.newTopicName=d||"New Node",this.contextMenu=o??!0,this.toolBar=r??!0,this.keypress=s??!0,this.mouseSelectionButton=a??0,this.direction=n??1,this.editable=i??!0,this.allowUndo=p??!0,this.scaleSensitivity=y??.1,this.scaleMax=S??1.4,this.scaleMin=T??.2,this.generateMainBranch=m||It,this.generateSubBranch=f||Ot,this.overflowHidden=g??!1,this.compact=x??!1,this.alignment=u??"root",this.handleWheel=h??!0,this.markdown=v||void 0,this.imageProxy=w||void 0,this.currentNodes=[],this.currentArrow=null,this.scaleVal=1,this.tempDirection=null,this.mobileMultiSelect=M??!1,this.panHelper=To(this),this.bus=pi(),this.container=document.createElement("div"),this.selectionContainer=l||this.container,this.container.className="map-container";let D=window.matchMedia("(prefers-color-scheme: dark)");this.theme=b||(D.matches?Le:_e);let A=document.createElement("div");A.className="map-canvas",this.map=A,this.container.setAttribute("tabindex","0"),this.container.appendChild(this.map),this.el.appendChild(this.container),this.nodes=document.createElement("me-nodes"),this.lines=ie("lines"),this.summarySvg=ie("summary"),this.linkController=ie("linkcontroller"),this.P2=document.createElement("div"),this.P3=document.createElement("div"),this.P2.className=this.P3.className="circle",this.P2.style.display=this.P3.style.display="none",this.line1=st(),this.line2=st(),this.linkController.appendChild(this.line1),this.linkController.appendChild(this.line2),this.arrowSvg=ie("topiclinks"),this.labelContainer=document.createElement("div"),this.labelContainer.className="label-container",this.map.appendChild(this.nodes),this.overflowHidden?this.container.style.overflow="hidden":this.disposable.push(di(this)),E&&(this.pasteHandler=E)}};Object.assign(W.prototype,So);var ee=["#d58375","#c79561","#79a494","#9694b9","#80a7b8","#b895ae"];function Wt(t,e){let n=N(t),i=new Set(e.collapsed),o=(s,a,l)=>{let c=n.get(s);return{id:s,topic:c.title,expanded:!i.has(s),branchColor:l,style:a===0?{color:"#243633",fontSize:"25px",background:"#ffffff",border:"none",fontWeight:"650"}:a===1?{color:"#293c37",background:`${l}26`,border:"none",fontSize:"16px",fontWeight:"550"}:{color:"#35453f",background:a===2?`${l}14`:"transparent",border:"none",fontSize:"14px"},children:a===0&&i.has(s)?[]:c.child_ids.map((d,p)=>o(d,a+1,a===0?ee[p%ee.length]:l))}},r=B(t);return{nodeData:r===L?{id:r,topic:"Pursuits",children:t.root_ids.map((s,a)=>o(s,1,ee[a%ee.length]))}:o(r,0,ee[0]),direction:W.RIGHT}}var Se=class{constructor(e,n,i,o){this.host=e;this.callbacks=o;this.snapshot=n,this.view=i,this.mind=new W({el:e,direction:W.RIGHT,contextMenu:!1,toolBar:!1,keypress:!1,allowUndo:!1,alignment:"nodes",scaleMin:.1,scaleMax:2,overflowHidden:!1,mouseSelectionButton:0,theme:{name:"Pursuit",type:"light",palette:ee,cssVar:{"--bgcolor":"#fdfdfb","--color":"#35453f","--selected":"#3d8172","--main-gap-x":"25px","--main-gap-y":"13px","--node-gap-x":"18px","--node-gap-y":"2px","--main-bgcolor":"#f5f6f1","--main-color":"#35453f","--main-border":"none","--root-bgcolor":"#ffffff","--root-color":"#243633","--root-border-color":"transparent","--root-radius":"7px","--main-radius":"6px","--topic-padding":"5px 8px","--map-padding":"60px"}},before:{beginEdit:r=>!!r&&this.canEdit(r.nodeObj.id),moveNodeIn:(r,s)=>this.allowDrop(r,s,"in"),moveNodeBefore:(r,s)=>this.allowDrop(r,s,"before"),moveNodeAfter:(r,s)=>this.allowDrop(r,s,"after"),addChild:()=>!1,insertSibling:()=>!1,insertParent:()=>!1,removeNodes:()=>!1,copyNode:()=>!1,copyNodes:()=>!1}}),this.mind.bus.addListener("selectNodes",r=>{if(this.rendering||!r.length)return;let s=r.at(-1);r.length>1&&this.mind.selectNode(this.mind.findEle(s.id)),this.callbacks.select(s.id),this.decorate()}),this.mind.bus.addListener("expandNode",r=>{this.callbacks.collapse(r.id,r.expanded===!1),this.decorate()}),this.mind.bus.addListener("operation",r=>{if(r.name==="beginEdit")this.attachInlineEditor(r.obj.id);else if(["moveNodeIn","moveNodeBefore","moveNodeAfter"].includes(r.name)&&"objs"in r)for(let s of r.objs){let a=s.parent?.id===L?null:s.parent?.id??null,l=s.parent?.children??[],c=l[l.indexOf(s)-1]?.id??null;this.callbacks.move({type:"move",id:s.id,parent_id:a,after_id:c})}}),this.mind.bus.addListener("scale",()=>this.saveViewport()),this.mind.bus.addListener("move",()=>this.saveViewport()),this.mind.init(Wt(n,i)),this.mind.container.setAttribute("role","tree"),this.mind.container.setAttribute("aria-label","Pursuit directions. Enter adds a sibling; Tab adds a child."),this.mind.container.setAttribute("aria-describedby","pm-keyboard-hint"),e.addEventListener("click",r=>{let s=r.target.closest("[data-map-marker]");if(!s)return;r.stopPropagation();let a=s.closest("me-tpc");if(s.dataset.mapMarker==="collapse"){this.callbacks.collapse(a.nodeObj.id,!this.view.collapsed.includes(a.nodeObj.id));return}this.callbacks.marker(a.nodeObj.id,s.dataset.mapMarker)},{capture:!0,signal:this.abort.signal}),e.addEventListener("pointerdown",r=>{r.target.closest("[data-map-marker]")&&r.stopPropagation()},{capture:!0,signal:this.abort.signal}),this.restoreViewport(i.viewport),this.decorate(),this.select(i.selected)}mind;snapshot;view;rendering=!1;editingId=null;editText;abort=new AbortController;viewportTimer;renderDeferred=!1;get editing(){return this.editingId!==null}canEdit(e){return this.snapshot.writable&&e!==L&&!!N(this.snapshot).get(e)?.editable}allowDrop(e,n,i){if(e.length!==1||!this.snapshot.writable||this.editing)return!1;let o=e[0].nodeObj.id,r=n.nodeObj.id,s=N(this.snapshot),a=i==="in"?r===L?null:r:s.get(r)?.parent_id??null,l=K(this.snapshot,a).filter(d=>d!==o),c=i==="in"?l.at(-1)??null:i==="after"?r:l[l.indexOf(r)-1]??null;try{return Te(this.snapshot,o,a,c),!0}catch(d){return this.callbacks.error(d.message),!1}}render(e,n){if(this.snapshot=e,this.view=n,this.editing){this.renderDeferred=!0;return}this.rendering=!0;let i=this.getViewport();this.mind.refresh(Wt(e,n)),this.mind.editable=e.writable,this.restoreViewport(i),this.decorate(),this.select(n.selected),this.rendering=!1}decorate(){let e=N(this.snapshot);for(let n of this.host.querySelectorAll("me-tpc")){let i=n.nodeObj.id,o=e.get(i);n.setAttribute("role","treeitem"),n.setAttribute("aria-label",o?.title??"Pursuits"),n.setAttribute("aria-selected",String(this.view.selected===i)),n.id=`pm-node-${i}`,n.tabIndex=-1,n.classList.toggle("pm-readonly",o?.editable===!1);let r=l=>l.parent?1+r(l.parent):1;n.setAttribute("aria-level",String(r(n.nodeObj))),o?.child_ids.length&&n.setAttribute("aria-expanded",String(!this.view.collapsed.includes(i))),n.querySelectorAll("[data-map-marker]").forEach(l=>l.remove());let s=[];o?.body&&s.push(["note","\u25A4","Open note"]),o?.focused&&s.push(["focus","\u2726","Remove Focus"]),o?.edges.length&&s.push(["relations","\u2197","Show related directions"]);for(let[l,c,d]of s){let p=document.createElement("button");p.type="button",p.tabIndex=-1,p.dataset.mapMarker=l,p.className="pm-node-marker",p.textContent=c,p.title=d,p.setAttribute("aria-label",`${d}: ${o.title}`),n.append(p)}if(n.parentElement?.tagName==="ME-ROOT"&&o?.child_ids.length){let l=document.createElement("button");l.type="button",l.tabIndex=-1,l.dataset.mapMarker="collapse",l.className="pm-node-marker",l.textContent=this.view.collapsed.includes(i)?"\u2295":"\u2296",l.setAttribute("aria-label",`${this.view.collapsed.includes(i)?"Expand":"Collapse"} ${o.title}`),n.append(l)}let a=n.parentElement?.querySelector("me-epd");a&&(a.setAttribute("role","button"),a.setAttribute("aria-label",`${this.view.collapsed.includes(i)?"Expand":"Collapse"} ${o?.title??"branch"}`),a.setAttribute("aria-expanded",String(!this.view.collapsed.includes(i))))}}select(e,n=!1){if(e)try{let i=this.mind.findEle(e),o=this.rendering;this.rendering=!0,this.mind.selectNode(i),this.rendering=o,this.view.selected=e,this.mind.container.setAttribute("aria-activedescendant",i.id),this.host.querySelectorAll('[aria-selected="true"]').forEach(r=>r.setAttribute("aria-selected","false")),i.setAttribute("aria-selected","true"),n&&this.centerReadableNode(i)}catch{}}centerReadableNode(e){this.mind.map.style.transition="none",this.mind.scaleVal<1&&this.mind.scale(1);let n=this.mind.container.getBoundingClientRect(),i=e.getBoundingClientRect();this.mind.move(n.left+n.width/2-i.left-i.width/2,n.top+n.height/2-i.top-i.height/2,!1),this.saveViewport()}async beginEdit(e,n){this.editing||!this.canEdit(e)||(this.editText=n,this.select(e,!0),await this.mind.beginEdit(this.mind.findEle(e)))}attachInlineEditor(e){let n=this.host.querySelector("#input-box");if(!n)return;this.editingId=e,this.editText!==void 0&&(n.textContent=this.editText),this.editText=void 0,n.setAttribute("role","textbox"),n.setAttribute("aria-label","Direction title"),n.setAttribute("aria-multiline","false"),this.callbacks.editStart(e,n.textContent??"");let i=!1;n.addEventListener("input",()=>this.callbacks.editInput(n.textContent??"")),n.addEventListener("keydown",o=>{o.isComposing||(o.key==="Escape"&&(i=!0),o.key==="Enter"&&o.shiftKey&&(o.preventDefault(),o.stopImmediatePropagation(),n.blur()))},{capture:!0}),n.addEventListener("blur",()=>{let o=n.innerText?.trim()??n.textContent?.trim()??"";queueMicrotask(()=>{this.editingId=null,this.callbacks.editEnd(e,o,i),this.renderDeferred&&(this.renderDeferred=!1,this.render(this.snapshot,this.view))})},{once:!0}),queueMicrotask(()=>{n.focus();let o=document.createRange();o.selectNodeContents(n),window.getSelection()?.removeAllRanges(),window.getSelection()?.addRange(o)})}highlight(e){this.host.querySelectorAll("me-tpc").forEach(n=>n.classList.toggle("pm-search-hit",e.has(n.nodeObj.id)))}focus(){this.mind.container.focus({preventScroll:!0})}fit(){this.mind.scaleFit(),this.saveViewport()}zoom(e){this.mind.scale(Math.max(.1,Math.min(2,this.mind.scaleVal+e)))}getViewport(){let e=new DOMMatrixReadOnly(getComputedStyle(this.mind.map).transform);return{x:e.m41,y:e.m42,scale:this.mind.scaleVal}}restoreViewport(e){if(!e){this.mind.scaleFit();return}this.mind.scaleVal=e.scale,this.mind.map.style.transformOrigin="50% 50%",this.mind.map.style.transform=`translate3d(${e.x}px, ${e.y}px, 0) scale(${e.scale})`}saveViewport(){clearTimeout(this.viewportTimer),this.viewportTimer=setTimeout(()=>this.callbacks.viewport(this.getViewport()),150)}destroy(){clearTimeout(this.viewportTimer),this.abort.abort(),this.mind.destroy()}};function No(t,e){let n=new Set(e),i=N(t),o=[],r=s=>{i.has(s)&&(o.push(s),n.has(s)||i.get(s).child_ids.forEach(r))};return B(t)===L&&o.push(L),t.root_ids.forEach(r),o}function Ce(t,e){let n=N(t),i=e.collapsed.filter(s=>n.get(s)?.child_ids.length),o=e.selected;(!o||o!==L&&!n.has(o))&&(o=B(t)),o===L&&t.root_ids.length===1&&(o=t.root_ids[0]);let r=n.get(o)?.parent_id;for(;r;)i.includes(r)&&(o=r),r=n.get(r)?.parent_id;return{...e,selected:o,collapsed:i}}function Ft(t,e,n){let i=Ce(t,e),o=i.selected,r=N(t).get(o),s=new Set(i.collapsed),a=No(t,s);if(n==="ArrowDown"||n==="ArrowUp"){let l=n==="ArrowDown"?1:-1;i.selected=a[Math.max(0,Math.min(a.length-1,a.indexOf(o)+l))]??o}else n==="ArrowRight"?o===L?i.selected=t.root_ids[0]??L:s.has(o)?s.delete(o):i.selected=r?.child_ids[0]??o:n==="ArrowLeft"?r?.child_ids.length&&!s.has(o)?s.add(o):i.selected=r?.parent_id??B(t):n==="Home"?i.selected=B(t):n==="End"&&(i.selected=a.at(-1)??o);return{...i,collapsed:[...s]}}function qt(t,e=!1){if(t.isComposing)return null;let n=t.ctrlKey||t.metaKey,i=t.key.toLowerCase();return e?n&&i==="s"?"save":i==="escape"?"escape":null:n?i==="z"?t.shiftKey?"redo":"undo":i==="y"?"redo":i==="f"?"search":i==="0"?"fit":null:t.altKey?null:t.key.startsWith("Arrow")||i==="home"||i==="end"?"navigate":i==="f2"?"rename":i==="enter"?t.shiftKey?"note":"sibling":i==="tab"?t.shiftKey?"promote":"child":i==="delete"||i==="backspace"?"delete":i===" "?"collapse":i==="n"?"note":i==="f"?"focus":i==="escape"?"escape":null}function Ke(t,e,n){let i=N(t),o=new Set(e.collapsed),r=i.get(n)?.parent_id;for(;r;)o.delete(r),r=i.get(r)?.parent_id;return{...e,selected:n,collapsed:[...o]}}function Kt(t,e){try{let n=JSON.parse(t.getItem(`rightmemory:pursuit-map:${e}`)??"null"),i=n?.viewport;return{selected:typeof n?.selected=="string"?n.selected:null,collapsed:Array.isArray(n?.collapsed)?n.collapsed.filter(o=>typeof o=="string"):[],...i&&[i.x,i.y,i.scale].every(Number.isFinite)&&i.scale>=.1&&i.scale<=2?{viewport:i}:{}}}catch{return{selected:null,collapsed:[]}}}function Ut(t,e,n){try{t.setItem(`rightmemory:pursuit-map:${e}`,JSON.stringify(n))}catch{}}function ko(t){let e=async(n,i)=>{try{return(await t(`/api/pursuit-map${n}`,i===void 0?void 0:{method:"POST",body:JSON.stringify(i)})).data}catch(o){let r=o;throw new ne(r.message,r.status??0,r.detail?.snapshot)}};return{load:()=>e(""),mutate:(n,i)=>e("/operations",{expected_revision:n,operation:i}),history:(n,i,o)=>e(`/${n}`,{expected_revision:i,commit:o})}}var _o=` + + +
+
+ + + + + +
100%
+

Enter \xB7 sibling Tab \xB7 child Double-click \xB7 rename

+
+`;async function er(t,e){return Lo(t,ko(e))}async function Lo(t,e){let n=await e.load();return new Ue(t,n,e)}var Ue=class{constructor(e,n,i){this.host=e;e.className="pursuit-map",e.innerHTML=_o,this.queue=new ce(n,i),this.view=Ce(n,Kt(localStorage,n.root_key)),this.renderer=new Se(this.$(".pm-canvas"),n,this.view,{select:o=>{this.view.selected=o,this.saveView(),this.updateTools()},collapse:(o,r)=>this.setCollapsed(o,r),move:o=>{this.mutate(o)},editStart:(o,r)=>this.editStarted(o,r),editInput:o=>{this.drafts.title&&(this.drafts.title.text=o)},editEnd:(o,r,s)=>this.editEnded(o,r,s),marker:(o,r)=>{this.select(o),this.command(r)},viewport:o=>{this.view.viewport=o,this.$(".pm-zoom span").textContent=`${Math.round(o.scale*100)}%`,this.saveView()},error:o=>this.toast(o)}),this.unsubscribe=this.queue.subscribe(o=>this.changed(o)),e.addEventListener("click",o=>{let r=o.target.closest("button[data-command]");r?.disabled||r&&(this.$(".pm-more").open=!1,this.command(r.dataset.command))},{signal:this.abort.signal}),this.$(".pm-search input").addEventListener("input",()=>this.search(),{signal:this.abort.signal}),this.$(".pm-search input").addEventListener("keydown",o=>{o.key==="Enter"&&(o.preventDefault(),this.nextMatch(o.shiftKey?-1:1)),o.key==="Escape"&&(o.preventDefault(),this.closeSearch())},{signal:this.abort.signal}),this.$(".pm-note textarea").addEventListener("input",o=>{this.drafts.note&&(this.drafts.note.text=o.target.value),this.updateNote(!1)},{signal:this.abort.signal}),document.addEventListener("keydown",o=>this.keydown(o),{capture:!0,signal:this.abort.signal}),window.addEventListener("beforeunload",o=>{(this.queue.pendingCount||this.drafts.dirty)&&(o.preventDefault(),o.returnValue="")},{signal:this.abort.signal}),window.addEventListener("focus",()=>{this.active&&!this.renderer.editing&&this.refresh().catch(o=>this.toast(o.message))},{signal:this.abort.signal}),this.$(".pm-toast-close").addEventListener("click",()=>{this.$(".pm-toast").hidden=!0},{signal:this.abort.signal}),this.updateTools(),this.renderDiagnostics(n),requestAnimationFrame(()=>this.renderer.focus())}queue;renderer;view;drafts=new de;abort=new AbortController;active=!0;matches=[];matchIndex=-1;toastTimer;unsubscribe;$(e){return this.host.querySelector(e)}saveView(){Ut(localStorage,this.queue.snapshot.root_key,this.view)}get snapshot(){return this.queue.snapshot}get selected(){return N(this.snapshot).get(this.view.selected??"")}displaySnapshot(){let e=this.drafts.title;if(e?.operation.type==="create")try{return te(this.snapshot,{...e.operation,title:e.text||"New direction"},e.id)}catch{return this.snapshot}return this.snapshot}render(){let e=this.displaySnapshot();this.view=Ce(e,this.view),this.renderer.render(e,this.view),this.renderer.highlight(new Set(this.matches)),this.updateTools(),this.saveView()}changed(e){if(e.remapped){let{from:n,to:i}=e.remapped;this.view.selected===n&&(this.view.selected=i),this.view.collapsed=this.view.collapsed.map(o=>o===n?i:o),this.drafts.remap(n,i)}this.drafts.reconcile(e.snapshot),this.render(),this.updateNote(),this.renderDiagnostics(e.snapshot),e.error&&this.toast(`${e.error.message} The map was reloaded; unsaved text is kept.`,void 0,0)}updateTools(){let e=this.selected,n=this.snapshot.writable;this.$('[data-command="undo"]').disabled=!this.queue.canUndo,this.$('[data-command="redo"]').disabled=!this.queue.canRedo;for(let o of["rename","delete","promote","focus"])this.$(`[data-command="${o}"]`).disabled=!n||!e?.editable;this.$('[data-command="note"]').disabled=!e,this.$('[data-command="child"]').disabled=!n||!!e&&!e.editable,this.$('[data-command="sibling"]').disabled=!n,this.$('[data-command="root"]').disabled=!n,this.$('[data-command="collapse"]').disabled=!e?.child_ids.length,this.$('[data-command="focus"]').setAttribute("aria-pressed",String(!!e?.focused));let i=this.$(".pm-save-status");i.textContent=this.queue.pendingCount?`Saving${this.queue.pendingCount>1?` (${this.queue.pendingCount})`:"\u2026"}`:n?"Saved":"Read-only",i.classList.toggle("pm-saving",!!this.queue.pendingCount)}renderDiagnostics(e){let n=this.$(".pm-diagnostics");if(n.replaceChildren(),n.hidden=e.writable&&!e.diagnostics.length,n.hidden)return;let i=document.createElement("strong");i.textContent=e.writable?"Map notices":"This map is read-only",n.append(i);let o=document.createElement("details"),r=document.createElement("summary");r.textContent="Show diagnostics",o.append(r);for(let s of e.diagnostics){let a=document.createElement("p");a.textContent=typeof s=="string"?s:s.message??JSON.stringify(s),o.append(a)}n.append(o)}select(e,n=!1){this.view=Ke(this.displaySnapshot(),this.view,e),this.render(),this.renderer.select(e,n)}setCollapsed(e,n){this.view.collapsed=this.view.collapsed.filter(i=>i!==e),n&&this.view.collapsed.push(e),this.render()}async command(e){try{switch(e){case"child":this.create("child");break;case"sibling":this.create("sibling");break;case"root":this.create("root");break;case"rename":this.selected?.editable&&await this.renderer.beginEdit(this.selected.id);break;case"promote":{let n=this.view.selected?Je(this.snapshot,this.view.selected):null;n&&await this.mutate(n);break}case"delete":await this.remove();break;case"focus":this.selected?.editable&&await this.mutate({type:"set_focus",id:this.selected.id,focused:!this.selected.focused});break;case"collapse":this.selected?.child_ids.length&&this.setCollapsed(this.selected.id,!this.view.collapsed.includes(this.selected.id));break;case"note":this.openNote();break;case"save-note":await this.saveNote();break;case"close-note":await this.saveNote()&&(this.$(".pm-note").hidden=!0,this.drafts.note=null,this.renderer.focus());break;case"discard-note":this.drafts.note=null,this.$(".pm-note").hidden=!0,this.renderer.focus();break;case"relations":this.openRelations();break;case"close-relations":this.$(".pm-relations").hidden=!0,this.renderer.focus();break;case"search":this.$(".pm-search").hidden=!1,this.$(".pm-search input").focus(),this.$(".pm-search input").select();break;case"close-search":this.closeSearch();break;case"next-result":this.nextMatch(1);break;case"previous-result":this.nextMatch(-1);break;case"fit":this.renderer.fit(),this.renderer.focus();break;case"zoom-in":this.renderer.zoom(.1);break;case"zoom-out":this.renderer.zoom(-.1);break;case"undo":case"redo":{let n=await this.queue.history(e);n?.selected_id&&this.select(n.selected_id,!0),n&&this.toast(e==="undo"?"Undone.":"Redone.");break}}}catch(n){this.toast(n.message,void 0,0)}}keydown(e){if(!this.active)return;let n=e.target,i=n.matches("input,textarea,[contenteditable]");if(n.id==="input-box")return;let o=qt(e,i);if(o){if(i&&o==="save"&&n.closest(".pm-note")){e.preventDefault(),this.command("save-note");return}i||!n.closest(".pm-canvas")&&!["search","undo","redo","fit","escape"].includes(o)||(e.preventDefault(),e.stopPropagation(),o==="navigate"?(this.view=Ft(this.snapshot,this.view,e.key),this.render(),this.renderer.select(this.view.selected,!0)):o==="escape"?(this.closeSearch(),this.$(".pm-relations").hidden=!0,this.$(".pm-more").open=!1,this.$(".pm-help").open=!1):this.command(o))}}create(e,n){if(!this.snapshot.writable||this.renderer.editing)return;let i=this.selected,o=e==="root"?null:e==="child"?i?.id??null:i?.parent_id??null,r=K(this.snapshot,o),s=e==="sibling"&&i?i.id:r.at(-1)??null,a=n?.id??`${Xe}${crypto.randomUUID()}`,l=n?.operation.type==="create"?n.operation:{type:"create",parent_id:o,after_id:s,title:""};this.drafts.title=n??{id:a,temporaryId:a,text:"",operation:l},l.parent_id&&(this.view=Ke(this.snapshot,this.view,l.parent_id)),this.view.collapsed=this.view.collapsed.filter(c=>c!==l.parent_id),this.view.selected=a,this.render(),this.renderer.beginEdit(a,this.drafts.title.text)}editStarted(e,n){this.drafts.title?.id!==e&&(this.drafts.title={id:e,text:n,operation:{type:"rename",id:e,title:n}})}editEnded(e,n,i){let o=this.drafts.title;if(!(!o||o.id!==e)){if(this.drafts.title=null,i||!n){this.render();return}if(o.operation.type==="rename"&&N(this.snapshot).get(e)?.title===n){this.render();return}o.text=n,o.operation={...o.operation,title:n},this.drafts.savingTitles.push(o),this.queue.enqueue(o.operation,o.temporaryId).then(()=>{this.drafts.savingTitles=this.drafts.savingTitles.filter(r=>r!==o)}).catch(r=>{this.drafts.failedTitle(o,r.message),this.render(),this.renderRecovery()}),this.renderer.focus()}}renderRecovery(){let e=this.$(".pm-recovery");if(e.replaceChildren(),e.hidden=!this.drafts.failedTitles.length,e.hidden)return;let n=document.createElement("strong");n.textContent="Unsaved titles kept here",e.append(n);for(let i of this.drafts.failedTitles){let o=document.createElement("div"),r=document.createElement("span");r.textContent=i.text,r.title=i.error??"";let s=document.createElement("button");s.type="button",s.textContent="Edit again",s.addEventListener("click",()=>{if(this.renderer.editing)return;let l=i.operation,c=N(this.snapshot);if(l.type==="create"&&l.parent_id&&!c.has(l.parent_id)||l.type==="rename"&&!c.has(l.id)){this.toast("That direction was removed elsewhere. The unsaved title is still here for you to copy.",void 0,0);return}this.drafts.failedTitles=this.drafts.failedTitles.filter(d=>d!==i),l.type==="create"?this.create("child",i):(this.drafts.title=i,this.select(i.id,!0),this.renderer.beginEdit(i.id,i.text)),this.renderRecovery()});let a=document.createElement("button");a.type="button",a.textContent="Discard",a.addEventListener("click",()=>{this.drafts.failedTitles=this.drafts.failedTitles.filter(l=>l!==i),this.renderRecovery()}),o.append(r,s,a),e.append(o)}}async mutate(e){try{return await this.queue.enqueue(e)}catch(n){return this.toast(n.message,void 0,0),null}}async remove(){let e=this.selected;if(!e?.editable||!this.snapshot.writable)return;this.view.selected=Ze(this.snapshot,e.id);let n=this.queue.enqueue({type:"delete",id:e.id}),i=async()=>{try{let o=await n;this.queue.canUndo&&this.snapshot.git_head===o.commit?await this.command("undo"):this.toast("Newer edits exist. Use the toolbar Undo to undo them in order.")}catch{}};this.toast(`\u201C${e.title}\u201D removed.`,{label:"Undo",action:()=>{i()}},0);try{(await n).repaired_references.length&&this.toast(`\u201C${e.title}\u201D removed; broken references repaired.`,{label:"Undo",action:()=>{i()}},0)}catch{}}openNote(){let e=this.selected;if(!e)return;let n=this.drafts.note;if(n&&n.id!==e.id&&(n.saving||n.text!==n.savedText)){this.toast("Save or discard the open note before opening another."),this.$(".pm-note textarea").focus();return}n?.id!==e.id&&this.drafts.openNote(e.id,e.body),this.$(".pm-note").hidden=!1,this.updateNote(),this.$(".pm-note textarea").focus()}updateNote(e=!0){let n=this.drafts.note;if(!n)return;let i=N(this.snapshot).get(n.id);this.$(".pm-note h2").textContent=i?.title??"Direction removed elsewhere";let o=this.$(".pm-note textarea");e&&o.value!==n.text&&(o.value=n.text),o.readOnly=!i?.editable||!this.snapshot.writable;let r=this.$(".pm-note-status");r.textContent=n.error?`${n.error} Your text is kept. Review the map, then save again.`:n.saving?"Saving\u2026":n.text!==n.savedText?"Unsaved changes":"Saved",r.classList.toggle("pm-error",!!n.error),this.$('[data-command="save-note"]').disabled=n.saving||!i?.editable||!this.snapshot.writable,this.$('[data-command="discard-note"]').hidden=n.text===n.savedText&&!n.error}async saveNote(){let e=this.drafts.note;if(!e)return!0;if(e.saving)return!1;if(e.text===e.savedText&&!e.error)return!0;let n=e.text;e.saving=!0,this.updateNote(!1);try{let i=await this.queue.enqueue({type:"edit_body",id:e.id,body:n});return this.drafts.noteSaved(i.selected_id??e.id,n),this.updateNote(!1),this.drafts.note?.text===n}catch(i){return this.drafts.noteFailed(e.id,i.message),this.updateNote(!1),!1}}openRelations(){let e=this.selected;if(!e)return;let n=this.$(".pm-relations"),i=this.$(".pm-relations > div");i.replaceChildren();let o={ref:"Reference",depends:"Depends on",supports:"Supports",contradicts:"Contradicts",related:"Related to"},r=N(this.snapshot);for(let[s,a]of e.edges){let l=r.get(a),c=document.createElement(l?"button":"p");c.textContent=`${o[s]??"Related"} \xB7 ${l?.title??"Memory reference"}`,l&&c.addEventListener("click",()=>this.select(l.id,!0)),i.append(c)}e.edges.length||(i.textContent="No related directions."),n.hidden=!1}search(){let e=this.$(".pm-search input").value.trim().toLocaleLowerCase();this.matches=e?this.snapshot.items.filter(n=>`${n.title} +${n.body}`.toLocaleLowerCase().includes(e)).map(n=>n.id):[],this.matchIndex=-1,this.nextMatch(1)}nextMatch(e){this.matches.length&&(this.matchIndex=(this.matchIndex+e+this.matches.length)%this.matches.length,this.select(this.matches[this.matchIndex],!0)),this.$(".pm-search-count").textContent=this.matches.length?`${this.matchIndex+1} / ${this.matches.length}`:this.$(".pm-search input").value?"No results":"",this.renderer.highlight(new Set(this.matches))}closeSearch(){this.$(".pm-search").hidden=!0,this.matches=[],this.renderer.highlight(new Set),this.renderer.focus()}toast(e,n,i=6e3){clearTimeout(this.toastTimer);let o=this.$(".pm-toast");o.hidden=!1,this.$(".pm-toast > span").textContent=e;let r=this.$(".pm-toast > button:not(.pm-toast-close)");r.hidden=!n,r.textContent=n?.label??"",r.onclick=n?.action??null,i&&(this.toastTimer=setTimeout(()=>{o.hidden=!0},i))}async refresh(){await this.queue.reload()}get hasUnsavedChanges(){return this.queue.pendingCount>0||this.drafts.dirty}setActive(e){this.active=e,e?(this.render(),this.renderer.focus()):this.saveView()}destroy(){this.unsubscribe(),clearTimeout(this.toastTimer),this.abort.abort(),this.renderer.destroy()}};export{ko as apiTransport,Lo as mountMap,er as mountPursuitMap}; diff --git a/rightmemory/web/static/styles.css b/rightmemory/web/static/styles.css index df7571b..fa22068 100644 --- a/rightmemory/web/static/styles.css +++ b/rightmemory/web/static/styles.css @@ -506,6 +506,13 @@ pre { margin-top: 8px; } +.pursuit-view .workspace { padding: 16px 18px; } +.pursuit-view .topbar { margin-bottom: 10px; align-items: center; } +.pursuit-view h1 { font-size: 21px; } +.pursuit-view #active-root { font-size: 11px; color: #929c96; } +.pursuit-view .message:empty { min-height: 0; margin: 0; } +.pursuit-view #refresh-button { min-height: 30px; padding: 4px 11px; font-size: 12px; } + @media (max-width: 960px) { .summary-grid, .settings-grid, diff --git a/skills/maintain-pursuit-map/SKILL.md b/skills/maintain-pursuit-map/SKILL.md new file mode 100644 index 0000000..f5388a4 --- /dev/null +++ b/skills/maintain-pursuit-map/SKILL.md @@ -0,0 +1,18 @@ +--- +name: maintain-pursuit-map +description: "Use when the user explicitly asks to inspect, create, rename, move, remove, focus, simplify, or reorganize the RightMemory Pursuit map." +--- + +# Maintain The Pursuit Map + +Pursuit is a user-owned map of ongoing directions. Use this workflow for an explicit request concerning that map. It edits canonical Markdown directly; it does not submit to Update or invoke another RightMemory role or maintenance skill. + +- Use the exact root supplied by the user. Otherwise run `rightmemory status`, or `rightmemory --profile status` for a requested profile, and use the reported root. If no root is reported, ask rather than guessing. +- Run `rightmemory validate --root ` before reading state. Do not read paths reported as non-regular. Read `rightmemory reference schema` and `rightmemory reference pursuit`, the complete affected subtree, its ancestors, Focus, and relevant graph references. Read additional Memory or project context when it is needed to understand the requested change. +- A precise requested edit is already authorized. For broad cleanup or ambiguous reorganization, present the proposed tree change concisely and wait for approval. An inspection request alone does not authorize changes. +- Require a clean active root, capture its branch and HEAD, and create a dedicated task branch and Git worktree from that HEAD, following the repository's worktree rules. Preserve unrelated changes; do not stash or discard them to proceed. +- Edit in the worktree. Preserve stable ids across rename and move, and generate globally unique ids for new headings without asking the user. Keep each node to its title, tree position, optional free-form body, and useful graph relationships. Execution detail and completed history belong outside the map. +- Update Focus, `F#` backing placement, and graph references together. Read Memory as context; change it here only for mechanically required reference repair unless the user separately requested Memory maintenance. Preserve existing content outside the approved change, including old body text that has not been selected for cleanup. +- Run `rightmemory validate --root ` immediately before committing and require success. Before landing, require the active root to remain clean, on the captured branch, and at the captured HEAD; otherwise stop and reassess against the new state. Land only the validated change, validate the active root again, and remove the temporary worktree after a successful landing. Push when sync is configured and the branch is published. + +Report the changed map structure, validation result, landed commit, and push result, or `no commit` for inspection or an unchanged map. diff --git a/skills/maintain-rightmemory/SKILL.md b/skills/maintain-rightmemory/SKILL.md index c2a4524..09fb31a 100644 --- a/skills/maintain-rightmemory/SKILL.md +++ b/skills/maintain-rightmemory/SKILL.md @@ -1,15 +1,15 @@ --- name: maintain-rightmemory -description: "Use when the user explicitly asks the current agent to inspect, organize, consolidate, or clean a RightMemory root directly. Preview proposed maintenance for approval, then apply only approved changes without invoking Update or another RightMemory model role." +description: "Use when the user explicitly asks to inspect or maintain RightMemory Memory, Agent Corrections, edit feedback, or supporting references. Preview changes for approval, then edit directly. Pursuit map editing is outside this workflow." --- - If the user specifies an exact root, use it. Otherwise run `rightmemory status` from the task's working directory, or `rightmemory --profile status` when the user specifies a profile, and use the reported `root:`. If no root is reported, stop and ask the user; do not infer or guess it. Never call `rightmemory update`, submit candidates, or invoke another RightMemory model role. - Run `rightmemory validate --root ` before reading RightMemory state. Never follow or read a path it reports as non-regular; treat other findings as maintenance evidence. -- Read the package-owned schema and Memory rules with `rightmemory reference schema` and `rightmemory reference memory`, then read available regular `/MEMORY.md`, `/PURSUITS.md`, relevant reachable backings, and relevant Git history or `update_records/*.json`. Before judging corresponding content, read `rightmemory reference pursuit`, `rightmemory reference agent-correction`, `rightmemory reference edit-correction`, or `rightmemory reference shared-view`; never edit immutable update records. Inspect the complete reachable state and identify concrete organization, consolidation, and cleanup that would make future retrieval and maintenance more coherent and useful. +- Read the package-owned schema and Memory rules with `rightmemory reference schema` and `rightmemory reference memory`, then read available regular `/MEMORY.md`, relevant reachable backings, and relevant Git history or `update_records/*.json`. Read Pursuit as context for Memory meaning and references, not as content to reorganize. Before judging corresponding content, read `rightmemory reference pursuit`, `rightmemory reference agent-correction`, `rightmemory reference edit-correction`, or `rightmemory reference shared-view`; never edit immutable update records. Inspect the affected reachable state for concrete organization, consolidation, and cleanup that would improve future use. - During cleanup, delete trivial items instead of compacting them. - Before writing, present a concise proposal split into `Strongly recommended` items that materially affect correctness, coherence, or future use and `Optional` items that are discretionary organization or clarity improvements. Name the affected files or ids, intended change, and reason; wait for explicit approval and apply only approved items. - After approval, require the active root to be clean, capture its branch and HEAD, and create a dedicated temporary Git worktree and branch from that HEAD. Do not stash, discard, or absorb unrelated changes. -- Apply the approved changes in the worktree. Edit Memory, Pursuit, their F# backings, Memory M#/S# backings, Agent Corrections, RightMemory Edit Feedback, and cross-tree references together when needed; keep Agent Corrections separate from root `corrections.md`. +- Apply the approved changes in the worktree to Memory, its F#/M#/S# backings, Agent Corrections, RightMemory Edit Feedback, and supporting references. Keep Agent Corrections separate from root `corrections.md`. Preserve ids referenced from Pursuit and leave the Pursuit document tree unchanged. Explicit map changes belong to the separate `maintain-pursuit-map` workflow; do not invoke it or queue map work from this skill. - Run `rightmemory validate --root ` and require a successful exit immediately before committing the approved result with subject `maintain: `. Use body sections `Strongly recommended:`, `Optional:`, and `Validation:`; omit an empty change section. -- Before landing, require the active root to remain clean and at the captured HEAD; otherwise stop and present a refreshed proposal. Land the exact validated commit with `git merge --ff-only`, without creating another commit, then require `rightmemory validate --root ` to pass. Remove the temporary worktree and branch; push when sync is configured. +- Before landing, require the active root to remain clean, on the captured branch, and at the captured HEAD; otherwise stop and present a refreshed proposal. Land the exact validated commit with `git merge --ff-only`, without creating another commit, then require `rightmemory validate --root ` to pass. Remove the temporary worktree and branch; push when sync is configured. - Report the applied changes, validation result, landed commit hash, and push result, or `no commit`. diff --git a/skills/rightmemory-auto-orchestrator-cli/SKILL.md b/skills/rightmemory-auto-orchestrator-cli/SKILL.md index 17f899c..5be0182 100644 --- a/skills/rightmemory-auto-orchestrator-cli/SKILL.md +++ b/skills/rightmemory-auto-orchestrator-cli/SKILL.md @@ -5,7 +5,7 @@ description: "Use when the user explicitly chooses automatic RightMemory orchest # Use RightMemory Automatically -RightMemory contains durable Memory, live Pursuit, and Agent Corrections. Use it as a client during ordinary work; do not curate its files directly unless the user explicitly requests maintenance. +RightMemory contains durable Memory, the user's Pursuit map, and Agent Corrections. Use it as a client during ordinary work. Pursuit is read-only context here: do not submit map changes through Update. Explicit map requests belong to the dedicated `maintain-pursuit-map` workflow. ## Session @@ -20,16 +20,14 @@ Choose one stable session id for the conversation and reuse it for every RightMe - Reconcile retrieved content with the current conversation and current evidence. Current user instructions take precedence over stale or conflicting stored state. - Apply relevant guidance in the work itself; do not merely quote or acknowledge it. -- If retrieved content is stale, wrong, misleading, or overbroad, do not follow it. Submit the problem and current evidence immediately. +- If retrieved Memory or Agent Corrections are stale, wrong, misleading, or overbroad, do not follow them. Submit the problem and current evidence immediately. Treat stale Pursuit as read-only context too; task activity does not authorize correcting the map. - Ordinary retrieval already considers relevant Agent Corrections. Use `rightmemory agent-corrections writing` or `rightmemory agent-corrections design` only when the user explicitly requests a whole-collection review. Use `writing` when changing expression or presentation alone could resolve the issue; use `design` when reasoning, decisions, actions, omissions, workflow, or behavior must change. Run both commands only when the requested review spans both collections. ## Submit Updates -- Submit automatically only when omitting the evidence would likely: - - cause poorer future decisions or substantial rediscovery (**Memory**); - - lose track of a meaningful pursuit, its place in the hierarchy, or its current direction (**Pursuit**). +- Submit automatic Memory evidence when omitting it would likely cause poorer future decisions or substantial rediscovery. - Do not submit transient progress, routine task results, unfinished work by itself, or implementation detail already adequately preserved in project-local artifacts. -- Submit once the evidence is clear and the work reaches a natural boundary—for example, when a pursuit becomes established or materially changes, a durable outcome settles, or the conversation moves away from the work. Completion is not required. If nothing passes the bar, submit nothing. +- Submit once the durable evidence is clear and the work reaches a natural boundary. Completion is not required. If nothing passes the bar, submit nothing; task progress and new ideas do not trigger a map-change suggestion. - Combine related evidence due at the same boundary into one candidate. State what happened, what is true now, and why it may matter; do not prescribe final stored wording, ids, classification, placement, or edits. - Submit with: diff --git a/skills/rightmemory-orchestrator-cli/SKILL.md b/skills/rightmemory-orchestrator-cli/SKILL.md index 990ce29..232aa73 100644 --- a/skills/rightmemory-orchestrator-cli/SKILL.md +++ b/skills/rightmemory-orchestrator-cli/SKILL.md @@ -5,7 +5,7 @@ description: "Use when the user explicitly chooses approval-gated RightMemory or # Use RightMemory -RightMemory contains durable Memory, live Pursuit, and Agent Corrections. Use it as a client during ordinary work; do not curate its files directly unless the user explicitly requests maintenance. +RightMemory contains durable Memory, the user's Pursuit map, and Agent Corrections. Use it as a client during ordinary work. Pursuit is read-only context here: do not submit map changes through Update. Explicit map requests belong to the dedicated `maintain-pursuit-map` workflow. Submissions are evidence for Update; Update decides whether and how RightMemory changes. @@ -22,15 +22,14 @@ Choose one stable session id for the conversation and reuse it for every RightMe - Reconcile retrieved content with the current conversation and current evidence. Current user instructions take precedence over stale or conflicting stored state. - Apply relevant guidance in the work itself; do not merely quote or acknowledge it. -- If retrieved content is stale, wrong, misleading, or overbroad, do not follow it. Submit the problem and current evidence immediately without waiting for approval. +- If retrieved Memory or Agent Corrections are stale, wrong, misleading, or overbroad, do not follow them. Submit the problem and current evidence immediately without waiting for approval. Treat stale Pursuit as read-only context too; task activity does not authorize correcting the map. - Ordinary retrieval already considers relevant Agent Corrections. Use `rightmemory agent-corrections writing` or `rightmemory agent-corrections design` only when the user explicitly requests a whole-collection review. Use `writing` when changing expression or presentation alone could resolve the issue; use `design` when reasoning, decisions, actions, omissions, workflow, or behavior must change. Run both commands only when the requested review spans both collections. ## Propose Updates - Apart from stale, wrong, misleading, or overbroad retrieved state, propose possible updates instead of submitting them automatically. - Propose only when omitting the evidence would likely: - - cause poorer future decisions or substantial rediscovery (**Memory**); - - lose track of a meaningful pursuit, its place in the hierarchy, or its current direction (**Pursuit**); or + - cause poorer future decisions or substantial rediscovery (**Memory**); or - make repetition of a settled, reusable failure pattern more likely (**Agent Corrections**). - Do not propose transient progress, routine task results, unfinished work by itself, or implementation detail already adequately preserved in project-local artifacts. - Name the apparent module and briefly explain why the evidence passes this bar. Do not propose final stored wording, ids, or edits. diff --git a/tests/asgi_client.py b/tests/asgi_client.py index 86d52b2..6505f0d 100644 --- a/tests/asgi_client.py +++ b/tests/asgi_client.py @@ -68,9 +68,10 @@ def json(self): class ASGITestClient: - def __init__(self, app) -> None: + def __init__(self, app, *, request_timeout_seconds: float = 5) -> None: self.app = app self.cookies = CookieJar() + self.request_timeout_seconds = request_timeout_seconds def get(self, path: str, *, headers: dict[str, str] | None = None) -> ASGIResponse: return self.request("GET", path, headers=headers) @@ -166,7 +167,7 @@ async def send(message): task = asyncio.create_task(self.app(scope, receive, send)) try: - await asyncio.wait_for(response_complete.wait(), timeout=5) + await asyncio.wait_for(response_complete.wait(), timeout=self.request_timeout_seconds) except TimeoutError as exc: if task.done(): await task diff --git a/tests/test_async_update.py b/tests/test_async_update.py index 685670d..73eba60 100644 --- a/tests/test_async_update.py +++ b/tests/test_async_update.py @@ -223,9 +223,9 @@ def test_live_detached_worker_stops_cooperatively_at_wait_boundary(self): error = "" if process.poll() is None else process.communicate(timeout=1)[1] self.assertIsNotNone(worker_pid, error) - # The full suite runs several process-heavy Git integration modules in - # parallel, so leave enough room for this detached child to be scheduled. - stopped = store.stop_worker(timeout_seconds=30) + # A delayed startup can miss next_flush_at and enter a full 30-second + # idle poll. The default stop timeout leaves room for that poll and exit. + stopped = store.stop_worker() _, stderr_text = process.communicate(timeout=10) return_code = process.returncode worker_state = json.loads(store._worker_state_path().read_text(encoding="utf-8")) diff --git a/tests/test_graph.py b/tests/test_graph.py index 6a9f91b..88486a4 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -4,7 +4,16 @@ import unittest from pathlib import Path -from rightmemory.graph import KNOWN_EDGE_TYPES, build_graph_manifest, build_mf_manifest +from rightmemory.graph import ( + KNOWN_EDGE_TYPES, + block_body_text, + build_graph_manifest, + build_mf_manifest, + parse_addressable_heading, + remove_edge_targets, + render_heading_line, + replace_heading_title, +) from rightmemory.tools import MemoryTools @@ -129,6 +138,72 @@ def test_recursive_f_backing_paths_are_diagnosed(self): self.assertTrue(any("cyclic F# backing path" in error for error in manifest.errors)) +class PursuitGrammarTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + (self.root / "MEMORY.md").write_bytes(b"# Memory\n") + + def test_title_only_and_free_markdown_body_validate_without_fields(self): + body = "\r\nA **note**, a [link](https://example.test), and a list. \r\n\r\n- Ordinary list\r\n\r\n```md\r\n## An example, not a map group\r\n```\r\n" + (self.root / "PURSUITS.md").write_bytes( + ("# Pursuits\r\n\r\n## Title only {#first}\r\n\r\n## Notes {#notes}\r\n" + body).encode() + ) + manifest = build_graph_manifest(self.root) + self.assertEqual(manifest.errors, []) + self.assertEqual(set(manifest.items), {"first", "notes"}) + self.assertEqual(block_body_text(manifest, manifest.items["notes"]), body) + + def test_legacy_next_keeps_all_actions_and_graph_looking_bullets_as_body(self): + body = ( + "\n**State:** Old context.\n\n**Next:**\n" + "- `do` Continue.\n- `research` Unknown old action.\n" + "- `not a slug` No validation as a node. -> [rel:missing]\n" + "- `looks-like-node` Still a Next bullet. -> []\n" + "- An ordinary bullet.\n\n**Done when:** Older prose.\n" + "**Status:** Anything here is body.\n" + ) + (self.root / "PURSUITS.md").write_bytes(("# Pursuits\n\n## Old {#old}\n" + body).encode()) + manifest = build_graph_manifest(self.root) + self.assertEqual(manifest.errors, []) + self.assertEqual(set(manifest.items), {"old"}) + self.assertEqual(block_body_text(manifest, manifest.items["old"]), body) + + def test_legacy_next_stops_at_field_or_heading(self): + for boundary in ("**State:** Another field.", "## Other {#other}"): + with self.subTest(boundary=boundary): + (self.root / "PURSUITS.md").write_bytes(( + "# Pursuits\n\n## Old {#old}\n**Next:**\n" + "- `research` Kept.\n" + boundary + "\n- `broken` Missing edge list.\n" + ).encode()) + manifest = build_graph_manifest(self.root) + self.assertTrue(any("node `broken` must include an edge list" in error for error in manifest.errors)) + + def test_heading_helpers_share_edges_and_preserve_original_suffix(self): + line = "### 中文 and English {F#stable} -> [doc:target, dep:other ]\r\n" + heading = parse_addressable_heading(line) + self.assertEqual((heading.depth, heading.title, heading.id, heading.anchor_kind), + (3, "中文 and English", "stable", "F#")) + self.assertEqual(heading.edges, (("doc", "target"), ("dep", "other"))) + self.assertEqual(replace_heading_title(line, "Changed"), + "### Changed {F#stable} -> [doc:target, dep:other ]\r\n") + rendered = render_heading_line("中文 and English", "#", "stable", heading.edges, depth=2) + self.assertEqual(parse_addressable_heading(rendered).edges, heading.edges) + for invalid in ("", " ", "two\nlines", "Anchor {#hidden}"): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + render_heading_line(invalid, "#", "stable") + + def test_edge_removal_keeps_other_tokens_and_prose_byte_stable(self): + heading = "## Target mention gone {#source} -> [rel:gone, dep:keep , doc:gone]\r\n" + node = " - `fact` Prose mentions gone. → [doc:gone]\n" + self.assertEqual(remove_edge_targets(heading, {"gone"}), + "## Target mention gone {#source} -> [ dep:keep ]\r\n") + self.assertEqual(remove_edge_targets(node, {"gone"}), + " - `fact` Prose mentions gone. → []\n") + self.assertEqual(remove_edge_targets(heading, {"unrelated"}), heading) + + class RelAncestorValidationTests(unittest.TestCase): def setUp(self): self.tempdir = tempfile.TemporaryDirectory() diff --git a/tests/test_guidance.py b/tests/test_guidance.py index 6e65180..6f46b70 100644 --- a/tests/test_guidance.py +++ b/tests/test_guidance.py @@ -355,10 +355,12 @@ def test_existing_optional_inbox_is_included_in_initial_baseline(self): self.assertIn(GUIDANCE_INBOX_PATH, self.installer._initial_memory_files()) - def test_installer_does_not_activate_guidance_review_skill_by_default(self): + def test_installer_keeps_guidance_review_independent_from_orchestration(self): self.installer._install_skills() - self.assertFalse((self.skills_root / "review-agent-guidance-inbox").exists()) + self.assertTrue( + (self.skills_root / "review-agent-guidance-inbox" / "SKILL.md").is_file() + ) self.assertTrue( (self.skills_root / "rightmemory-auto-orchestrator" / "SKILL.md").is_file() ) diff --git a/tests/test_install.py b/tests/test_install.py index 1f6473b..298bafd 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1026,7 +1026,7 @@ def test_new_root_installs_current_memory_gitignore_allowlist(self): "!update_records/*.json\n", ) - def test_cli_agent_installs_only_automatic_orchestration_skill_by_default(self): + def test_cli_agent_installs_automatic_orchestration_and_independent_skills(self): with tempfile.TemporaryDirectory() as tempdir: root = Path(tempdir) memory_root = root / "memory" @@ -1042,6 +1042,7 @@ def test_cli_agent_installs_only_automatic_orchestration_skill_by_default(self): guidance_reviewer_exists = ( skills_target / "review-agent-guidance-inbox" / "SKILL.md" ).is_file() + map_maintainer_exists = (skills_target / "maintain-pursuit-map" / "SKILL.md").is_file() install_stamp = (memory_root / ".runtime" / "install.stamp").read_text(encoding="utf-8") wrapper = (root / "home" / ".local" / "bin" / "rightmemory").read_text(encoding="utf-8") installed_skill_directories = sorted(path.name for path in skills_target.iterdir() if path.is_dir()) @@ -1053,10 +1054,10 @@ def test_cli_agent_installs_only_automatic_orchestration_skill_by_default(self): self.assertFalse(retriever_exists) self.assertFalse(orchestrator_exists) self.assertTrue(auto_orchestrator_exists) - self.assertFalse(maintainer_exists) - self.assertFalse(guidance_reviewer_exists) + self.assertTrue(maintainer_exists) + self.assertTrue(guidance_reviewer_exists) + self.assertTrue(map_maintainer_exists) self.assertIn("rightmemory-auto-orchestrator", result.stdout) - self.assertNotIn("review-agent-guidance-inbox", result.stdout) self.assertIn("export PYTHONUTF8=1", wrapper) self.assertIn('export RIGHTMEMORY_ROOT="', wrapper) self.assertIn('exec "', wrapper) @@ -1064,7 +1065,12 @@ def test_cli_agent_installs_only_automatic_orchestration_skill_by_default(self): self.assertFalse((skills_target / "rightmemory-edit-correction-rules.md").exists()) self.assertEqual( installed_skill_directories, - ["rightmemory-auto-orchestrator"], + [ + "maintain-pursuit-map", + "maintain-rightmemory", + "review-agent-guidance-inbox", + "rightmemory-auto-orchestrator", + ], ) def test_rerun_preserves_managed_examples_and_user_state_byte_for_byte(self): @@ -1189,10 +1195,11 @@ def test_default_install_uses_standalone_and_default_skill_targets(self): for target in (home / ".codex" / "skills", home / ".claude" / "skills"): self.assertFalse((target / "rightmemory-edit-correction-rules.md").exists()) self.assertTrue((target / "rightmemory-auto-orchestrator" / "SKILL.md").exists()) - self.assertFalse((target / "maintain-rightmemory").exists()) + self.assertTrue((target / "maintain-rightmemory" / "SKILL.md").is_file()) + self.assertTrue((target / "maintain-pursuit-map" / "SKILL.md").is_file()) self.assertFalse((target / "memory-retriever").exists()) self.assertFalse((target / "rightmemory-orchestrator").exists()) - self.assertFalse((target / "review-agent-guidance-inbox").exists()) + self.assertTrue((target / "review-agent-guidance-inbox" / "SKILL.md").is_file()) self.assertFalse((target / "memory-orchestrator").exists()) self.assertFalse((home / ".codex" / "skills" / "memory-curator").exists()) self.assertFalse((home / ".claude" / "skills" / "memory-dreamer").exists()) diff --git a/tests/test_install_core.py b/tests/test_install_core.py index a91b052..0bd27c9 100644 --- a/tests/test_install_core.py +++ b/tests/test_install_core.py @@ -148,48 +148,28 @@ def test_default_install_selects_standalone_mode_and_both_skill_targets(self): installer_type.return_value.run.assert_called_once_with() def test_install_skills_populates_every_selected_target(self): - with tempfile.TemporaryDirectory() as tempdir: - root = Path(tempdir) - targets = [root / "codex-skills", root / "claude-skills"] - installer = Installer(REPO_ROOT, "standalone", root / "memory", targets) - for index, target in enumerate(targets): - target.mkdir() - current_schema = ( - REPO_ROOT / "rightmemory" / "reference" / "rightmemory-schema.md" - ).read_text(encoding="utf-8") - loose_schema = current_schema - if index == 1: - loose_schema = loose_schema.replace("\n", "\r\n") - (target / "rightmemory-schema.md").write_bytes(loose_schema.encode("utf-8")) - (target / "rightmemory-edit-correction-rules.md").write_bytes( - ( - REPO_ROOT - / "rightmemory" - / "reference" - / "RIGHTMEMORY_EDIT_CORRECTION_RULES.md" - ).read_bytes() - ) - - current_schema_hash = install_core.sha256(current_schema.encode("utf-8")).hexdigest() - with ( - patch("builtins.print"), - patch.dict( - install_core.LEGACY_LOOSE_REFERENCE_SHA256, - {"rightmemory-schema.md": current_schema_hash}, - ), - ): - installer._install_skills() - - for target in targets: - self.assertFalse((target / "rightmemory-schema.md").exists()) - self.assertFalse((target / "rightmemory-edit-correction-rules.md").exists()) - self.assertTrue((target / "rightmemory-auto-orchestrator" / "SKILL.md").is_file()) - self.assertFalse((target / "memory-retriever").exists()) - self.assertFalse((target / "rightmemory-orchestrator").exists()) - self.assertFalse((target / "maintain-rightmemory").exists()) - self.assertFalse((target / "review-agent-guidance-inbox").exists()) + for mode in ("standalone", "cli-agent"): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + targets = [root / "codex-skills", root / "claude-skills"] + installer = Installer(REPO_ROOT, mode, root / "memory", targets) + with patch("builtins.print"): + installer._install_skills() + + for target in targets: + self.assertFalse((target / "rightmemory-schema.md").exists()) + self.assertFalse((target / "rightmemory-edit-correction-rules.md").exists()) + for skill_name in ( + "rightmemory-auto-orchestrator", + "maintain-rightmemory", + "review-agent-guidance-inbox", + "maintain-pursuit-map", + ): + self.assertTrue((target / skill_name / "SKILL.md").is_file()) + self.assertFalse((target / "memory-retriever").exists()) + self.assertFalse((target / "rightmemory-orchestrator").exists()) - def test_install_skills_removes_only_exact_managed_previous_defaults(self): + def test_install_skills_replaces_managed_variants_and_keeps_independent_skills(self): with tempfile.TemporaryDirectory() as tempdir: root = Path(tempdir) target = root / "skills" @@ -210,7 +190,7 @@ def test_install_skills_removes_only_exact_managed_previous_defaults(self): ) modified = target / "memory-retriever" / "SKILL.md" modified.write_text( - modified.read_text(encoding="utf-8") + "\nUser-owned change.\n", + "User-owned file.\n", encoding="utf-8", ) @@ -219,10 +199,32 @@ def test_install_skills_removes_only_exact_managed_previous_defaults(self): self.assertTrue(modified.is_file()) self.assertFalse((target / "rightmemory-orchestrator").exists()) - self.assertFalse((target / "maintain-rightmemory").exists()) - self.assertFalse((target / "review-agent-guidance-inbox").exists()) + self.assertTrue((target / "maintain-rightmemory" / "SKILL.md").is_file()) + self.assertTrue((target / "review-agent-guidance-inbox" / "SKILL.md").is_file()) + self.assertTrue((target / "maintain-pursuit-map" / "SKILL.md").is_file()) self.assertTrue((target / "rightmemory-auto-orchestrator" / "SKILL.md").is_file()) + def test_remove_old_loose_reference_accepts_managed_bytes_and_known_line_endings(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + canonical = root / "canonical.bin" + canonical.write_bytes(b"managed-data\n") + installer = Installer(REPO_ROOT, "standalone", root / "memory", [root / "skills"]) + for index, content in enumerate((b"managed-data\n", b"managed-data\r\n")): + target = root / f"target-{index}" + target.mkdir() + legacy = target / "rightmemory-schema.md" + legacy.write_bytes(content) + with ( + patch("builtins.print"), + patch.dict( + install_core.LEGACY_LOOSE_REFERENCE_SHA256, + {"rightmemory-schema.md": install_core.sha256(b"managed-data\n").hexdigest()}, + ), + ): + installer._remove_old_loose_reference(target, legacy.name, canonical) + self.assertFalse(legacy.exists()) + def test_install_skills_preserves_modified_loose_reference(self): with tempfile.TemporaryDirectory() as tempdir: root = Path(tempdir) diff --git a/tests/test_install_windows.py b/tests/test_install_windows.py index 9a425e8..c2f239c 100644 --- a/tests/test_install_windows.py +++ b/tests/test_install_windows.py @@ -31,8 +31,8 @@ def test_windows_installer_installs_command_wrapper_and_skills(self): wrapper = root / "local" / "RightMemory" / "bin" / "rightmemory.cmd" wrapper_text = wrapper.read_text(encoding="utf-8") - memory = (memory_root / "MEMORY.md").read_text(encoding="utf-8") - pursuits = (memory_root / "PURSUITS.md").read_text(encoding="utf-8") + memory_exists = (memory_root / "MEMORY.md").is_file() + pursuits_exist = (memory_root / "PURSUITS.md").is_file() gitignore = (memory_root / ".gitignore").read_text(encoding="utf-8") tracked_gitignore = self._git(memory_root, "ls-files", "--", ".gitignore") install_stamp = (memory_root / ".runtime" / "install.stamp").read_text(encoding="utf-8") @@ -48,6 +48,10 @@ def test_windows_installer_installs_command_wrapper_and_skills(self): ).exists() maintainer = (skills_target / "maintain-rightmemory" / "SKILL.md") maintainer_exists = maintainer.exists() + map_maintainer_exists = (skills_target / "maintain-pursuit-map" / "SKILL.md").is_file() + guidance_reviewer_exists = ( + skills_target / "review-agent-guidance-inbox" / "SKILL.md" + ).is_file() legacy_orchestrator_exists = (skills_target / "memory-orchestrator").exists() leaked_requirement_file = (root / "3.11").exists() @@ -56,8 +60,8 @@ def test_windows_installer_installs_command_wrapper_and_skills(self): self.assertIn("rightmemory package into", result.stdout) self.assertIn("rightmemory is available in this PowerShell session", result.stdout) self.assertIn("SetEnvironmentVariable", result.stdout) - self.assertIn("rightmemory:example:start", memory) - self.assertIn("rightmemory:pursuit-example:start", pursuits) + self.assertTrue(memory_exists) + self.assertTrue(pursuits_exist) self.assertFalse((memory_root / "PURSUIT_RULES.md").exists()) self.assertFalse((memory_root / "AGENT_CORRECTION_MEMORY_RULES.md").exists()) self.assertTrue(gitignore.startswith("*\n!.gitignore\n")) @@ -69,7 +73,9 @@ def test_windows_installer_installs_command_wrapper_and_skills(self): self.assertFalse(retriever_exists) self.assertFalse(orchestrator_exists) self.assertTrue(auto_orchestrator_exists) - self.assertFalse(maintainer_exists) + self.assertTrue(maintainer_exists) + self.assertTrue(map_maintainer_exists) + self.assertTrue(guidance_reviewer_exists) self.assertIn("rightmemory-auto-orchestrator", result.stdout) self.assertFalse(legacy_orchestrator_exists) self.assertIn('set "PYTHONUTF8=1"', wrapper_text) @@ -115,10 +121,14 @@ def test_windows_installer_bootstraps_both_modes_with_tracked_gitignore(self): for skill_name in ( "memory-retriever", "rightmemory-orchestrator", + ): + self.assertFalse((skills_target / skill_name).exists()) + for skill_name in ( "maintain-rightmemory", "review-agent-guidance-inbox", + "maintain-pursuit-map", ): - self.assertFalse((skills_target / skill_name).exists()) + self.assertTrue((skills_target / skill_name / "SKILL.md").is_file()) def test_windows_installer_reports_missing_uv_before_writes(self): with tempfile.TemporaryDirectory() as tempdir: diff --git a/tests/test_isolated_write_candidate_validation.py b/tests/test_isolated_write_candidate_validation.py index 5e439b2..d152b60 100644 --- a/tests/test_isolated_write_candidate_validation.py +++ b/tests/test_isolated_write_candidate_validation.py @@ -1,5 +1,7 @@ +import subprocess import unittest from pathlib import Path +from unittest.mock import patch from rightmemory.isolated_write import ( IsolatedWriteSupervisor, @@ -188,6 +190,134 @@ def callback(worktree: Path) -> str: with self.assertRaisesRegex(RuntimeError, "non-memory paths: corrections\\.md"): IsolatedWriteSupervisor(self.root, "update").run(callback) + def test_update_rejects_pursuit_root_and_backing_commits_from_git(self): + originals = { + "PURSUITS.md": "# Pursuits\n\n## Branch {F#branch}\n", + "PURSUIT_branch.md": "# Existing direction {#direction}\n", + } + for path, content in originals.items(): + (self.root / path).write_text(content, encoding="utf-8") + self._git("add", *originals) + self._git("commit", "-m", "pursuit: existing human map") + start_head = self._git("rev-parse", "HEAD") + start_memory = (self.root / "MEMORY.md").read_bytes() + + for path in originals: + with self.subTest(path=path): + def callback(worktree: Path) -> None: + content = originals[path].replace("Branch", "Changed branch").replace("Existing", "Changed") + (worktree / path).write_text(content, encoding="utf-8") + self._append_memory(worktree, "- `two` otherwise valid memory → []\n") + self._git("add", path, "MEMORY.md", cwd=worktree) + self._git("commit", "-m", "update: forbidden pursuit edit", cwd=worktree) + + with self.assertRaisesRegex(RuntimeError, "non-memory paths: PURSUIT"): + IsolatedWriteSupervisor(self.root, "update").run(callback) + self.assertEqual(self._git("rev-parse", "HEAD"), start_head) + self.assertEqual(self._git("status", "--short"), "") + self.assertEqual((self.root / "MEMORY.md").read_bytes(), start_memory) + self.assertEqual((self.root / path).read_text(encoding="utf-8"), originals[path]) + self._assert_isolated_cleanup() + + def test_update_rejects_pursuit_rename_into_memory(self): + (self.root / "PURSUIT_branch.md").write_text("# Direction {#direction}\n", encoding="utf-8") + self._git("add", "PURSUIT_branch.md") + self._git("commit", "-m", "pursuit: existing detail") + start_head = self._git("rev-parse", "HEAD") + + def callback(worktree: Path) -> None: + self._git("mv", "PURSUIT_branch.md", "MEMORY_branch.md", cwd=worktree) + self._git("commit", "-m", "update: rename pursuit into memory", cwd=worktree) + + with self.assertRaisesRegex(RuntimeError, r"non-memory paths: PURSUIT_branch\.md"): + IsolatedWriteSupervisor(self.root, "update").run(callback) + self.assertEqual(self._git("rev-parse", "HEAD"), start_head) + self.assertTrue((self.root / "PURSUIT_branch.md").is_file()) + self.assertFalse((self.root / "MEMORY_branch.md").exists()) + self._assert_isolated_cleanup() + + def test_update_cannot_hide_pursuit_write_before_operation_squash(self): + original_pursuits = (self.root / "PURSUITS.md").read_bytes() + + def callback(worktree: Path) -> None: + (worktree / "PURSUITS.md").write_text("# Pursuits\n\n## Changed {#changed}\n", encoding="utf-8") + self._git("add", "PURSUITS.md", cwd=worktree) + self._git("commit", "-m", "update: forbidden intermediate pursuit", cwd=worktree) + (worktree / "PURSUITS.md").write_bytes(original_pursuits) + self._append_memory(worktree, "- `two` otherwise valid final memory → []\n") + self._git("add", "MEMORY.md", "PURSUITS.md", cwd=worktree) + self._git("commit", "-m", "update: restore pursuit", cwd=worktree) + + with self.assertRaisesRegex(RuntimeError, r"non-memory paths: PURSUITS\.md"): + IsolatedWriteSupervisor(self.root, "update").run( + callback, + operation_id="update-hidden-pursuit-write", + operation_input={"message": "change memory"}, + ) + self.assertEqual(self._git("rev-parse", "HEAD"), self.initial_head) + self.assertEqual((self.root / "PURSUITS.md").read_bytes(), original_pursuits) + self.assertEqual(self._git("status", "--short"), "") + self._assert_isolated_cleanup() + + def test_update_rechecks_old_prepared_pursuit_candidate_before_publication(self): + def callback(worktree: Path) -> None: + (worktree / "PURSUITS.md").write_text("# Pursuits\n\n## Changed {#changed}\n", encoding="utf-8") + self._git("add", "PURSUITS.md", cwd=worktree) + self._git("commit", "-m", "update: candidate prepared under old policy", cwd=worktree) + + with ( + patch.object(IsolatedWriteSupervisor, "_is_role_write_path", return_value=True), + patch.object(IsolatedWriteSupervisor, "_land_operation_commit", side_effect=RuntimeError("interrupted before publish")), + self.assertRaisesRegex(RuntimeError, "interrupted before publish"), + ): + IsolatedWriteSupervisor(self.root, "update").run( + callback, + operation_id="update-prepared-under-old-policy", + operation_input={"message": "candidate from old runtime"}, + ) + + with self.assertRaisesRegex(RuntimeError, r"non-memory paths: PURSUITS\.md"): + IsolatedWriteSupervisor(self.root, "update").recover_prepared() + self.assertEqual(self._git("rev-parse", "HEAD"), self.initial_head) + self.assertEqual(self._git("status", "--short"), "") + self.assertEqual((self.root / "PURSUITS.md").read_text(encoding="utf-8"), "# Pursuits\n") + + def test_update_lands_memory_graph_and_evidence_changes(self): + def callback(worktree: Path) -> None: + self._append_memory( + worktree, + "\n## Detail {F#detail}\n\n## Notes {M#notes}\n\n## Review {S#review}\n", + ) + (worktree / "MEMORY_detail.md").write_text("# Detail fact {#detail-fact}\n", encoding="utf-8") + (worktree / "MEMORY_notes.md").write_text("Free-form evidence.\n", encoding="utf-8") + (worktree / "MEMORY_SKILL_review.md").write_text("Review the evidence.\n", encoding="utf-8") + self._git("add", "MEMORY.md", "MEMORY_detail.md", "MEMORY_notes.md", "MEMORY_SKILL_review.md", cwd=worktree) + self._git("commit", "-m", "update: add durable memory", cwd=worktree) + + result = IsolatedWriteSupervisor(self.root, "update").run(callback) + + self.assertEqual(result.commits_landed, 1) + self.assertEqual( + set(result.changed_paths), + {"MEMORY.md", "MEMORY_detail.md", "MEMORY_notes.md", "MEMORY_SKILL_review.md"}, + ) + self.assertEqual(self._git("status", "--short"), "") + self._assert_isolated_cleanup() + + def test_sync_reconciler_lands_pursuit_root_and_backing_changes(self): + def callback(worktree: Path) -> None: + (worktree / "PURSUITS.md").write_text("# Pursuits\n\n## Shared direction {F#shared}\n", encoding="utf-8") + (worktree / "PURSUIT_shared.md").write_text("# Shared child {#shared-child}\n", encoding="utf-8") + self._git("add", "PURSUITS.md", "PURSUIT_shared.md", cwd=worktree) + self._git("commit", "-m", "sync: reconcile human pursuit map", cwd=worktree) + + result = IsolatedWriteSupervisor(self.root, "sync-reconciler").run(callback) + + self.assertEqual(result.commits_landed, 1) + self.assertEqual(set(result.changed_paths), {"PURSUITS.md", "PURSUIT_shared.md"}) + self.assertEqual(self._git("status", "--short"), "") + self._assert_isolated_cleanup() + def test_dreamer_rejects_pursuit_write(self): def callback(worktree: Path) -> None: (worktree / "PURSUITS.md").write_text( @@ -272,10 +402,10 @@ def callback(worktree: Path) -> None: self._git("add", "MEMORY.md", cwd=worktree) self._git("commit", "-m", "dreamer: break pursuit edge", cwd=worktree) - with self.assertRaisesRegex(RuntimeError, "dangling edge `dep:one`"): - IsolatedWriteSupervisor(self.root, "dreamer").run(callback) - - self.assertEqual(self._git("rev-parse", "HEAD"), current_head) + for role in ("dreamer", "update"): + with self.subTest(role=role), self.assertRaisesRegex(RuntimeError, "dangling edge `dep:one`"): + IsolatedWriteSupervisor(self.root, role).run(callback) + self.assertEqual(self._git("rev-parse", "HEAD"), current_head) def test_dirty_pursuit_rules_or_corrections_blocks_narrow_writer(self): for name in ( @@ -320,6 +450,191 @@ def callback(worktree: Path) -> str: with self.assertRaisesRegex(RuntimeError, "missing `### Proposed edit`"): IsolatedWriteSupervisor(self.root, "sync-reconciler").run(callback) + def test_pursuit_map_lands_only_exact_memory_edge_repairs(self): + _original, repaired = self._add_pursuit_repair_graph() + + result = IsolatedWriteSupervisor(self.root, "pursuit-map").run( + lambda worktree: self._commit_pursuit_graph(worktree, repaired, "pursuit: remove direction") + ) + + self.assertEqual(result.commits_landed, 1) + self.assertEqual(set(result.changed_paths), set(repaired)) + self.assertEqual(self._git("status", "--short"), "") + for path, content in repaired.items(): + if content is None: + self.assertFalse((self.root / path).exists()) + else: + self.assertEqual((self.root / path).read_bytes(), content) + self._assert_isolated_cleanup() + + def test_pursuit_map_rejects_semantic_or_extra_memory_changes_during_delete(self): + originals, repaired = self._add_pursuit_repair_graph() + start_head = self._git("rev-parse", "HEAD") + variants = { + "prose": repaired["MEMORY.md"].replace(b"Keep prose mentioning removed.", b"Curated prose."), + "surviving edge": repaired["MEMORY.md"].replace(b"[ doc:kept]", b"[]"), + "line endings": repaired["MEMORY.md"].replace(b"\r\n", b"\n"), + } + for label, memory in variants.items(): + with self.subTest(change=label): + candidate = {**repaired, "MEMORY.md": memory} + with self.assertRaisesRegex(RuntimeError, "only to repair references"): + IsolatedWriteSupervisor(self.root, "pursuit-map").run( + lambda worktree: self._commit_pursuit_graph(worktree, candidate, "pursuit: invalid memory change") + ) + self.assertEqual(self._git("rev-parse", "HEAD"), start_head) + self.assertEqual(self._git("status", "--short"), "") + for path, content in originals.items(): + self.assertEqual((self.root / path).read_bytes(), content) + self._assert_isolated_cleanup() + + def test_pursuit_map_rejects_memory_changes_without_deleted_pursuit_ids(self): + def callback(worktree: Path) -> None: + self._append_memory(worktree, "- `two` unauthorized durable fact → []\n") + self._git("add", "MEMORY.md", cwd=worktree) + self._git("commit", "-m", "pursuit: unauthorized memory curation", cwd=worktree) + + with self.assertRaisesRegex(RuntimeError, "only to repair references"): + IsolatedWriteSupervisor(self.root, "pursuit-map").run(callback) + self.assertEqual(self._git("rev-parse", "HEAD"), self.initial_head) + self._assert_isolated_cleanup() + + def test_pursuit_map_cannot_modify_fixed_correction_collections(self): + def callback(worktree: Path) -> None: + path = worktree / "MEMORY_agent-corrections-writing.md" + path.write_text("# Curated writing corrections\n", encoding="utf-8") + self._git("add", path.name, cwd=worktree) + self._git("commit", "-m", "pursuit: unauthorized corrections", cwd=worktree) + + with self.assertRaisesRegex(RuntimeError, r"non-memory paths: MEMORY_agent-corrections-writing\.md"): + IsolatedWriteSupervisor(self.root, "pursuit-map").run(callback) + self.assertEqual(self._git("rev-parse", "HEAD"), self.initial_head) + self._assert_isolated_cleanup() + + def test_pursuit_map_restores_exact_verified_deletion_and_can_delete_again(self): + originals, repaired = self._add_pursuit_repair_graph() + deletion = IsolatedWriteSupervisor(self.root, "pursuit-map").run( + lambda worktree: self._commit_pursuit_graph(worktree, repaired, "pursuit: remove direction") + ) + + with self.assertRaisesRegex(RuntimeError, "only to repair references"): + IsolatedWriteSupervisor(self.root, "pursuit-map").run( + lambda worktree: self._commit_pursuit_graph(worktree, originals, "pursuit: unverified restoration") + ) + self.assertEqual(self._git("rev-parse", "HEAD"), deletion.landed_commit) + + restoration = IsolatedWriteSupervisor( + self.root, "pursuit-map", pursuit_restore_commit=deletion.landed_commit + ).run(lambda worktree: self._commit_pursuit_graph(worktree, originals, "pursuit: undo deletion")) + self.assertEqual(restoration.commits_landed, 1) + for path, content in originals.items(): + self.assertEqual((self.root / path).read_bytes(), content) + + redo = IsolatedWriteSupervisor( + self.root, "pursuit-map", pursuit_restore_commit=restoration.landed_commit + ).run(lambda worktree: self._commit_pursuit_graph(worktree, repaired, "pursuit: redo deletion")) + self.assertEqual(redo.commits_landed, 1) + self.assertEqual(self._git("status", "--short"), "") + self._assert_isolated_cleanup() + + def test_pursuit_map_restore_rejects_memory_curation_hidden_in_source(self): + originals, repaired = self._add_pursuit_repair_graph() + unsafe_source = { + **repaired, + "MEMORY.md": repaired["MEMORY.md"].replace(b"Keep prose mentioning removed.", b"Unrelated curation."), + } + source = self._commit_pursuit_graph(self.root, unsafe_source, "sync: unrelated memory and pursuit changes") + + with self.assertRaisesRegex(RuntimeError, "only to repair references"): + IsolatedWriteSupervisor(self.root, "pursuit-map", pursuit_restore_commit=source).run( + lambda worktree: self._commit_pursuit_graph(worktree, originals, "pursuit: invalid reverse curation") + ) + self.assertEqual(self._git("rev-parse", "HEAD"), source) + self.assertEqual(self._git("status", "--short"), "") + self._assert_isolated_cleanup() + + def test_pursuit_map_restore_cannot_overwrite_later_memory_change(self): + originals, repaired = self._add_pursuit_repair_graph() + deletion = IsolatedWriteSupervisor(self.root, "pursuit-map").run( + lambda worktree: self._commit_pursuit_graph(worktree, repaired, "pursuit: remove direction") + ) + later_memory = repaired["MEMORY.md"] + b"\r\nLater durable context.\r\n" + current_head = self._commit_pursuit_graph( + self.root, {"MEMORY.md": later_memory}, "update: preserve later context" + ) + + with self.assertRaisesRegex(RuntimeError, "only to repair references"): + IsolatedWriteSupervisor( + self.root, "pursuit-map", pursuit_restore_commit=deletion.landed_commit + ).run(lambda worktree: self._commit_pursuit_graph(worktree, originals, "pursuit: invalid stale inverse")) + self.assertEqual(self._git("rev-parse", "HEAD"), current_head) + self.assertEqual((self.root / "MEMORY.md").read_bytes(), later_memory) + self.assertEqual(self._git("status", "--short"), "") + self._assert_isolated_cleanup() + + def test_update_cannot_request_pursuit_reference_restoration(self): + with self.assertRaises(ValueError): + IsolatedWriteSupervisor(self.root, "update", pursuit_restore_commit=self.initial_head) + + def test_pursuit_snapshot_batches_exact_immutable_blobs(self): + self._git("config", "core.autocrlf", "false") + repeated = "Notes with Chinese 中文.\r\n\r\nMore context.\r\n".encode("utf-8") + originals = { + "MEMORY.md": b"# Domain {#domain}\n\n## Notes {M#notes}\n\n## Copy {M#copy}\n\n## Empty {M#empty}\n", + "PURSUITS.md": b"# Pursuits\n", + "MEMORY_notes.md": repeated, + "MEMORY_copy.md": repeated, + "MEMORY_empty.md": b"", + } + commit = self._commit_pursuit_graph(self.root, originals, "memory: snapshot binary boundaries") + (self.root / "MEMORY_notes.md").write_bytes(b"Uncommitted replacement.\n") + + with patch("rightmemory.isolated_write.subprocess.run", wraps=subprocess.run) as run_git: + snapshot = IsolatedWriteSupervisor(self.root, "pursuit-map")._commit_graph_snapshot(self.root, commit) + + self.assertEqual(snapshot.files, originals) + self.assertEqual(snapshot.graph.errors, []) + blob_calls = [call for call in run_git.call_args_list if call.args[0][:2] == ["git", "cat-file"]] + self.assertEqual(len(blob_calls), 1) + + def _add_pursuit_repair_graph(self): + self._git("config", "core.autocrlf", "false") + original = { + "MEMORY.md": ( + b"# Domain {#domain} -> [dep:removed, doc:kept]\r\n\r\n" + b"Keep prose mentioning removed.\r\n\r\n" + b"```md\r\n- `example` unchanged example -> [dep:removed]\r\n```\r\n\r\n" + b"- `one` initial memory -> [dep:removed-child]\r\n\r\n" + b"## Detail {F#detail}\r\n" + ), + "MEMORY_detail.md": b"# Evidence {#evidence} -> [doc:removed-child, dep:kept]\n", + "PURSUITS.md": ( + b"# Pursuits\n\n## Focus\n\n- `removed`\n\n" + b"## Removed {F#removed}\n\n## Kept {#kept}\n" + ), + "PURSUIT_removed.md": b"# Removed child {#removed-child}\n", + } + self._commit_pursuit_graph(self.root, original, "pursuit: seed cross-root references") + repaired = { + "MEMORY.md": original["MEMORY.md"].replace(b"[dep:removed, doc:kept]", b"[ doc:kept]").replace( + b"[dep:removed-child]", b"[]" + ), + "MEMORY_detail.md": b"# Evidence {#evidence} -> [ dep:kept]\n", + "PURSUITS.md": b"# Pursuits\n\n## Focus\n\n## Kept {#kept}\n", + "PURSUIT_removed.md": None, + } + return original, repaired + + def _commit_pursuit_graph(self, worktree: Path, files: dict[str, bytes | None], subject: str) -> str: + for path, content in files.items(): + if content is None: + (worktree / path).unlink(missing_ok=True) + else: + (worktree / path).write_bytes(content) + self._git("add", "-A", "--", *files, cwd=worktree) + self._git("commit", "-m", subject, cwd=worktree) + return self._git("rev-parse", "HEAD", cwd=worktree) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_isolated_write_update_finalization.py b/tests/test_isolated_write_update_finalization.py index e8b1e7d..08dd22d 100644 --- a/tests/test_isolated_write_update_finalization.py +++ b/tests/test_isolated_write_update_finalization.py @@ -217,14 +217,18 @@ def test_external_result_can_be_superseded_by_another_lease_owner(self): self.assertEqual(record.effects, ()) self.assertTrue(record.outcome.metadata["superseded"]) - def test_update_lands_memory_and_pursuit_as_one_transaction(self): + def test_update_lands_memory_and_agent_corrections_as_one_transaction(self): + self._add_fixed_agent_correction_collections() + def callback(worktree: Path) -> str: self._append_memory(worktree, "- `durable` durable result → []\n") - (worktree / "PURSUITS.md").write_text( - "# Pursuits\n\n## Continue {#continue} \u2192 [dep:durable]\n", + correction = worktree / "MEMORY_agent-corrections-writing.md" + correction.write_text( + correction.read_text(encoding="utf-8") + + "\n### Keep the useful context\n\nPreserve the durable result.\n", encoding="utf-8", ) - self._git("add", "MEMORY.md", "PURSUITS.md", cwd=worktree) + self._git("add", "MEMORY.md", correction.name, cwd=worktree) self._git("commit", "-m", "rightmemory: unified update", cwd=worktree) return "updated" @@ -232,8 +236,8 @@ def callback(worktree: Path) -> str: self.assertEqual(result.commits_landed, 1) self.assertEqual(result.start_commit, self._git("rev-parse", f"{result.landed_commit}^")) - self.assertEqual(result.changed_paths, ("MEMORY.md", "PURSUITS.md")) - self.assertIn("continue", (self.root / "PURSUITS.md").read_text(encoding="utf-8")) + self.assertEqual(result.changed_paths, ("MEMORY.md", "MEMORY_agent-corrections-writing.md")) + self.assertIn("Preserve the durable result.", (self.root / "MEMORY_agent-corrections-writing.md").read_text(encoding="utf-8")) def test_sync_reconciler_preserves_structured_corrections_over_updater_ceiling(self): def callback(worktree: Path) -> str: diff --git a/tests/test_pursuit_store.py b/tests/test_pursuit_store.py new file mode 100644 index 0000000..7eb8138 --- /dev/null +++ b/tests/test_pursuit_store.py @@ -0,0 +1,454 @@ +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from rightmemory.graph import build_graph_manifest +from rightmemory.pursuit_store import PursuitStore, PursuitStoreError +from rightmemory.pursuit_tree import PursuitEdit, apply_operation +from tests.isolated_write_test_base import IsolatedWriteTestBase + + +class PursuitStoreTests(IsolatedWriteTestBase): + session_id = "test-editor-session-private-identity" + + def setUp(self): + super().setUp() + self._git("config", "core.autocrlf", "false") + self._git("config", "commit.gpgSign", "false") + (self.root / "MEMORY.md").write_bytes( + "# Memory\n\n## Context {#context}\n\n" + "- `memory-one` Stable context. → [dep:alpha]\n\n" + "## Other {#other}\n\nUnchanged body.\n".encode("utf-8") + ) + (self.root / "PURSUITS.md").write_bytes( + "# Pursuits\n\n## Focus\n\n- `alpha`\n\n" + "## Alpha {#alpha} → [dep:context]\n\nAlpha body.\n\n" + "### Child {#alpha-child}\n\nChild body.\n\n" + "## Beta {#beta}\n\nBeta body.\n".encode("utf-8") + ) + self._git("add", "MEMORY.md", "PURSUITS.md") + self._git("commit", "-m", "seed editable map") + self.initial_head = self._git("rev-parse", "HEAD") + self.store = PursuitStore(self.root) + + def _apply(self, operation, *, revision=None, session_id=None): + return self.store.apply( + operation, + revision or self.store.snapshot()["revision"], + session_id or self.session_id, + ) + + def _undo(self, result): + return self.store.undo(result["commit"], self.store.snapshot()["revision"], self.session_id) + + def _redo(self, result): + return self.store.redo(result["commit"], self.store.snapshot()["revision"], self.session_id) + + def _bytes(self): + return {path.name: path.read_bytes() for path in self.root.glob("*.md")} + + def _assert_unchanged(self, before, head=None): + self.assertEqual(self._bytes(), before) + self.assertEqual(self._git("rev-parse", "HEAD"), head or self.initial_head) + self.assertEqual(self._git("status", "--porcelain"), "") + self._assert_isolated_cleanup() + + def test_snapshot_contains_order_focus_revision_and_writability(self): + snapshot = self.store.snapshot() + self.assertEqual(snapshot["root_ids"], ["alpha", "beta"]) + items = {item["id"]: item for item in snapshot["items"]} + self.assertEqual(items["alpha"]["child_ids"], ["alpha-child"]) + self.assertEqual(items["alpha-child"]["parent_id"], "alpha") + self.assertEqual(snapshot["focus_ids"], ["alpha"]) + self.assertTrue(snapshot["valid"]) + self.assertTrue(snapshot["writable"]) + self.assertEqual(snapshot["diagnostics"], []) + self.assertEqual(snapshot["git_head"], self.initial_head) + self.assertEqual(snapshot["revision"], PursuitStore(self.root).snapshot()["revision"]) + self.assertFalse((self.root / ".runtime").exists()) + + def test_rename_lands_exactly_one_commit_and_preserves_memory_bytes(self): + before = self._bytes() + previous = self.store.snapshot() + result = self._apply({"type": "rename", "id": "alpha", "title": "Alpha 中文"}, revision=previous["revision"]) + self.assertTrue(result["undoable"]) + self.assertEqual(result["commit"], self._git("rev-parse", "HEAD")) + self.assertEqual(result["snapshot"]["git_head"], result["commit"]) + self.assertEqual(self._git("rev-list", "--count", f"{self.initial_head}..HEAD"), "1") + self.assertNotEqual(previous["revision"], result["snapshot"]["revision"]) + self.assertEqual((self.root / "MEMORY.md").read_bytes(), before["MEMORY.md"]) + item = next(item for item in result["snapshot"]["items"] if item["id"] == "alpha") + self.assertEqual(item["title"], "Alpha 中文") + self.assertEqual(item["body"], "Alpha body.") + self.assertEqual(item["edges"], [["dep", "context"]]) + self.assertNotIn(self.session_id, self._git("show", "--no-patch", "--format=%B", "HEAD")) + self.assertEqual(self._git("status", "--porcelain"), "") + self._assert_isolated_cleanup() + + def test_git_line_ending_conversion_is_rejected_before_publication(self): + before = self._bytes() + self._git("config", "core.autocrlf", "true") + self.assertEqual(self._git("status", "--porcelain"), "") + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "delete", "id": "alpha"}) + self.assertEqual(caught.exception.code, "read_only") + self.assertIn("line-ending", str(caught.exception)) + self._assert_unchanged(before) + + def test_consistent_crlf_root_preserves_bytes_through_delete_and_undo(self): + self._git("config", "core.autocrlf", "true") + for path in self.root.glob("*.md"): + path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n")) + self._git("add", "MEMORY.md", "PURSUITS.md") + before = self._bytes() + self.assertEqual(self._git("status", "--porcelain"), "") + deleted = self._apply({"type": "delete", "id": "alpha"}) + self.assertEqual( + (self.root / "MEMORY.md").read_bytes(), + before["MEMORY.md"].replace(b"[dep:alpha]", b"[]"), + ) + self._undo(deleted) + self.assertEqual(self._bytes(), before) + self.assertEqual(self._git("status", "--porcelain"), "") + self._assert_isolated_cleanup() + + def test_all_operations_validate_and_leave_the_root_clean(self): + create = self._apply({"type": "create", "parent_id": "alpha-child", "title": "Mixed 中文"}) + item_id = create["selected_id"] + self.assertIn("PURSUIT_alpha-child.md", self._bytes()) + results = [create] + for operation in ( + {"type": "edit_body", "id": item_id, "body": "A **free-form** note.\n\n第二行。"}, + {"type": "set_focus", "id": item_id, "focused": True}, + {"type": "move", "id": item_id, "parent_id": "beta", "after_id": None}, + {"type": "delete", "id": item_id}, + ): + results.append(self._apply(operation)) + self.assertEqual(build_graph_manifest(self.root).errors, []) + self.assertEqual(self._git("status", "--porcelain"), "") + self.assertEqual(len({result["commit"] for result in results}), 5) + self.assertNotIn("PURSUIT_alpha-child.md", self._bytes()) + self.assertNotIn(item_id, build_graph_manifest(self.root).items) + self._assert_isolated_cleanup() + + def test_no_change_does_not_create_a_history_entry(self): + snapshot = self.store.snapshot() + result = self._apply({"type": "rename", "id": "alpha", "title": "Alpha"}, revision=snapshot["revision"]) + self.assertIsNone(result["commit"]) + self.assertFalse(result["undoable"]) + self.assertEqual(result["snapshot"]["revision"], snapshot["revision"]) + self.assertEqual(self._git("rev-parse", "HEAD"), self.initial_head) + self._assert_isolated_cleanup() + + def test_malformed_operation_type_is_rejected_without_runtime_state(self): + before = self._bytes() + for value in (None, [], {}, 1): + with self.subTest(value=value): + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": value}) + self.assertEqual(caught.exception.code, "invalid_operation") + self._assert_unchanged(before) + self.assertFalse((self.root / ".runtime").exists()) + + def test_stale_revision_after_memory_commit_is_a_conflict(self): + revision = self.store.snapshot()["revision"] + self._append_memory(self.root, "\nAnother durable sentence.\n") + self._git("add", "MEMORY.md") + self._git("commit", "-m", "external memory change") + before, head = self._bytes(), self._git("rev-parse", "HEAD") + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}, revision=revision) + self.assertEqual((caught.exception.code, caught.exception.status), ("conflict", 409)) + self._assert_unchanged(before, head) + + def test_branch_switch_at_same_commit_invalidates_revision(self): + revision = self.store.snapshot()["revision"] + self._git("checkout", "-b", "different-active-branch") + before = self._bytes() + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}, revision=revision) + self.assertEqual(caught.exception.code, "conflict") + self._assert_unchanged(before) + + def test_untracked_unrelated_unicode_path_is_not_absorbed(self): + revision = self.store.snapshot()["revision"] + unrelated = self.root / "other notes 中文.txt" + unrelated.write_bytes(b"private unfinished work") + before = self._bytes() + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}, revision=revision) + self.assertEqual(caught.exception.code, "dirty_root") + self.assertEqual(self._bytes(), before) + self.assertEqual(unrelated.read_bytes(), b"private unfinished work") + self.assertEqual(self._git("rev-parse", "HEAD"), self.initial_head) + self.assertEqual(self._git("diff", "--cached", "--name-only"), "") + + def test_staged_unrelated_change_is_not_absorbed(self): + revision = self.store.snapshot()["revision"] + (self.root / "other.txt").write_bytes(b"staged work") + self._git("add", "other.txt") + index_tree = self._git("write-tree") + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}, revision=revision) + self.assertEqual(caught.exception.code, "dirty_root") + self.assertEqual(self._git("write-tree"), index_tree) + self.assertEqual(self._git("rev-parse", "HEAD"), self.initial_head) + + def test_invalid_root_is_readable_but_cannot_be_edited(self): + with (self.root / "MEMORY.md").open("ab") as handle: + handle.write(b"\n## Duplicate {#alpha}\n") + self._git("add", "MEMORY.md") + self._git("commit", "-m", "external invalid graph") + snapshot = self.store.snapshot() + before, head = self._bytes(), self._git("rev-parse", "HEAD") + self.assertFalse(snapshot["writable"]) + self.assertFalse(snapshot["valid"]) + self.assertTrue(snapshot["diagnostics"]) + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "beta", "title": "Changed"}, revision=snapshot["revision"]) + self.assertEqual(caught.exception.code, "invalid_root") + self._assert_unchanged(before, head) + + def test_candidate_validation_failure_leaves_active_root_unchanged(self): + before = self._bytes() + + def invalid_edit(candidate, _operation): + with (candidate / "PURSUITS.md").open("ab") as handle: + handle.write(b"\n## Duplicate {#alpha}\n") + return PursuitEdit(("PURSUITS.md",), (), "alpha", "pursuit: invalid fixture") + + with patch("rightmemory.pursuit_store.apply_operation", side_effect=invalid_edit): + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + self.assertEqual(caught.exception.code, "invalid_operation") + self._assert_unchanged(before) + + def test_editor_cannot_smuggle_memory_curation_into_a_valid_commit(self): + before = self._bytes() + + def unsafe_edit(candidate, operation): + edit = apply_operation(candidate, operation) + with (candidate / "MEMORY.md").open("ab") as handle: + handle.write(b"\nUnrelated semantic curation.\n") + return replace(edit, changed_paths=(*edit.changed_paths, "MEMORY.md")) + + with patch("rightmemory.pursuit_store.apply_operation", side_effect=unsafe_edit): + with self.assertRaisesRegex(PursuitStoreError, "may change Memory only"): + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + self._assert_unchanged(before) + + def test_editor_cannot_change_undeclared_paths(self): + before = self._bytes() + + def unsafe_edit(candidate, operation): + edit = apply_operation(candidate, operation) + (candidate / "other.txt").write_bytes(b"unexpected") + return edit + + with patch("rightmemory.pursuit_store.apply_operation", side_effect=unsafe_edit): + with self.assertRaisesRegex(PursuitStoreError, "outside its declared operation"): + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + self._assert_unchanged(before) + self.assertFalse((self.root / "other.txt").exists()) + + def test_publication_rejects_external_head_change_without_overwriting_it(self): + external = {} + + def race(candidate, operation): + result = apply_operation(candidate, operation) + self._append_memory(self.root, "\nExternal committed prose.\n") + self._git("add", "MEMORY.md") + self._git("commit", "-m", "external concurrent change") + external["bytes"] = self._bytes() + external["head"] = self._git("rev-parse", "HEAD") + return result + + with patch("rightmemory.pursuit_store.apply_operation", side_effect=race): + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + self.assertEqual(caught.exception.code, "conflict") + self._assert_unchanged(external["bytes"], external["head"]) + + def test_byte_revision_fence_catches_worktree_bytes_not_reported_by_git(self): + # Simulate a transient filesystem edit that Git's cached status misses; + # the independent graph byte fence must still reject publication. + external = {} + original_state = self.store._repository_state + + def cached_state(): + return replace(original_state(), dirty_paths=()) + + def race(candidate, operation): + result = apply_operation(candidate, operation) + self._append_memory(self.root, "\nExternal unindexed prose.\n") + external["bytes"] = self._bytes() + return result + + with patch.object(self.store, "_repository_state", side_effect=cached_state), patch( + "rightmemory.pursuit_store.apply_operation", side_effect=race, + ): + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + self.assertEqual(caught.exception.code, "conflict") + self.assertEqual(self._bytes(), external["bytes"]) + self.assertEqual(self._git("rev-parse", "HEAD"), self.initial_head) + self._assert_isolated_cleanup() + + def test_preexisting_hidden_git_index_changes_are_read_only(self): + for flag, undo_flag in (("--assume-unchanged", "--no-assume-unchanged"), ("--skip-worktree", "--no-skip-worktree")): + with self.subTest(flag=flag): + self._git("update-index", flag, "MEMORY.md") + snapshot = self.store.snapshot() + self.assertFalse(snapshot["writable"]) + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}, revision=snapshot["revision"]) + self.assertEqual(caught.exception.code, "read_only") + self._git("update-index", undo_flag, "MEMORY.md") + + def test_ignored_untracked_graph_file_cannot_be_overwritten(self): + (self.root / ".gitignore").write_bytes(b"PURSUIT_alpha-child.md\n") + self._git("add", ".gitignore") + self._git("commit", "-m", "external ignore setting") + backing = self.root / "PURSUIT_alpha-child.md" + backing.write_bytes(b"# Real untracked memory data\n") + before, head = self._bytes(), self._git("rev-parse", "HEAD") + self.assertEqual(self._git("status", "--porcelain"), "") + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "create", "parent_id": "alpha-child", "title": "New child"}) + self.assertEqual(caught.exception.code, "dirty_root") + self._assert_unchanged(before, head) + + def test_delete_undo_redo_restore_exact_edges_focus_and_bytes(self): + before = self._bytes() + deleted = self._apply({"type": "delete", "id": "alpha"}) + deletion_bytes = self._bytes() + self.assertEqual(deleted["snapshot"]["root_ids"], ["beta"]) + self.assertEqual(deleted["snapshot"]["focus_ids"], []) + self.assertTrue(any(reference["kind"] == "edge" for reference in deleted["repaired_references"])) + restored = self._undo(deleted) + self.assertEqual(self._bytes(), before) + self.assertNotEqual(restored["commit"], deleted["commit"]) + self.assertTrue(any(reference.get("action") == "restored" for reference in restored["repaired_references"])) + redone = self._redo(restored) + self.assertEqual(self._bytes(), deletion_bytes) + self.assertEqual(self._git("rev-list", "--count", f"{self.initial_head}..HEAD"), "3") + self.assertEqual(self._git("status", "--porcelain"), "") + self._undo(redone) + self.assertEqual(self._bytes(), before) + self.assertEqual(build_graph_manifest(self.root).errors, []) + self._assert_isolated_cleanup() + + def test_multiple_undo_redo_use_new_commits_without_rewriting_history(self): + before = self._bytes() + first = self._apply({"type": "rename", "id": "alpha", "title": "First change"}) + second = self._apply({"type": "rename", "id": "beta", "title": "Second change"}) + edited = self._bytes() + undo_second = self._undo(second) + undo_first = self._undo(first) + self.assertEqual(self._bytes(), before) + self._redo(undo_first) + self._redo(undo_second) + self.assertEqual(self._bytes(), edited) + self.assertEqual(self._git("rev-list", "--count", f"{self.initial_head}..HEAD"), "6") + self.assertEqual(self._git("status", "--porcelain"), "") + + def test_history_cannot_revert_another_sessions_commit(self): + result = self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + before = self._bytes() + with self.assertRaises(PursuitStoreError) as caught: + self.store.undo(result["commit"], result["snapshot"]["revision"], "another-session") + self.assertEqual((caught.exception.code, caught.exception.status), ("history_forbidden", 403)) + self._assert_unchanged(before, result["commit"]) + + def test_revert_conflict_does_not_change_active_files_or_head(self): + first = self._apply({"type": "rename", "id": "alpha", "title": "First rename"}) + latest = self._apply({"type": "rename", "id": "alpha", "title": "Later rename"}) + before = self._bytes() + with self.assertRaises(PursuitStoreError) as caught: + self._undo(first) + self.assertEqual(caught.exception.code, "history_conflict") + self._assert_unchanged(before, latest["commit"]) + + def test_history_requires_exact_authenticated_commit_not_git_revision_syntax(self): + before = self._bytes() + for value in ("HEAD", "HEAD~1", "-n", "0" * 40): + with self.subTest(value=value): + with self.assertRaises(PursuitStoreError) as caught: + self.store.undo(value, self.store.snapshot()["revision"], self.session_id) + self.assertEqual(caught.exception.code, "history_forbidden") + self._assert_unchanged(before) + + def test_copied_editor_trailers_do_not_authenticate_another_tree(self): + result = self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + message = self._git("show", "--no-patch", "--format=%B", result["commit"]) + self._append_memory(self.root, "\nNot an editor change.\n") + self._git("add", "MEMORY.md") + self._git("commit", "-m", message) + before, forged = self._bytes(), self._git("rev-parse", "HEAD") + with self.assertRaises(PursuitStoreError) as caught: + self.store.undo(forged, self.store.snapshot()["revision"], self.session_id) + self.assertEqual(caught.exception.code, "history_forbidden") + self._assert_unchanged(before, forged) + + def test_history_rejects_a_non_editor_commit(self): + before = self._bytes() + with self.assertRaises(PursuitStoreError) as caught: + self.store.undo(self.initial_head, self.store.snapshot()["revision"], self.session_id) + self.assertEqual(caught.exception.code, "history_forbidden") + self._assert_unchanged(before) + + def test_history_rejects_editor_commit_outside_active_ancestry(self): + result = self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + self._git("reset", "--hard", self.initial_head) + before = self._bytes() + with self.assertRaises(PursuitStoreError) as caught: + self.store.undo(result["commit"], self.store.snapshot()["revision"], self.session_id) + self.assertEqual(caught.exception.code, "history_forbidden") + self._assert_unchanged(before) + + def test_history_rejects_repeated_undo_and_external_intervening_commits(self): + result = self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + undone = self._undo(result) + before = self._bytes() + with self.assertRaises(PursuitStoreError) as caught: + self._undo(result) + self.assertEqual(caught.exception.code, "history_conflict") + self._assert_unchanged(before, undone["commit"]) + self._append_memory(self.root, "\nExternal update after undo.\n") + self._git("add", "MEMORY.md") + self._git("commit", "-m", "external history") + before, head = self._bytes(), self._git("rev-parse", "HEAD") + with self.assertRaises(PursuitStoreError) as caught: + self._redo(undone) + self.assertEqual(caught.exception.code, "history_conflict") + self._assert_unchanged(before, head) + + def test_git_hooks_cannot_mutate_candidate_or_published_tree(self): + hooks = self.root / ".git" / "hooks" + hooks.mkdir(exist_ok=True) + for name in ("pre-commit", "post-merge"): + hook = hooks / name + hook.write_bytes(b"#!/bin/sh\nprintf '\\nHook changed memory.\\n' >> MEMORY.md\n") + hook.chmod(0o755) + before_memory = (self.root / "MEMORY.md").read_bytes() + result = self._apply({"type": "rename", "id": "alpha", "title": "Changed"}) + self.assertEqual((self.root / "MEMORY.md").read_bytes(), before_memory) + self.assertTrue(result["snapshot"]["writable"]) + self.assertEqual(self._git("status", "--porcelain"), "") + + def test_detached_and_in_progress_git_roots_are_read_only(self): + self._git("checkout", "--detach", self.initial_head) + snapshot = self.store.snapshot() + self.assertTrue(snapshot["valid"]) + self.assertFalse(snapshot["writable"]) + with self.assertRaises(PursuitStoreError) as caught: + self._apply({"type": "rename", "id": "alpha", "title": "Changed"}, revision=snapshot["revision"]) + self.assertEqual(caught.exception.code, "read_only") + self._git("checkout", "-b", "active-again") + (self.root / ".git" / "MERGE_HEAD").write_text(self.initial_head + "\n", encoding="ascii") + self.assertFalse(self.store.snapshot()["writable"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pursuit_tree.py b/tests/test_pursuit_tree.py new file mode 100644 index 0000000..1eea07e --- /dev/null +++ b/tests/test_pursuit_tree.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from rightmemory.graph import build_graph_manifest +from rightmemory.pursuit_tree import PursuitOperationError, apply_operation, load_pursuit_tree +from rightmemory.tools import MemoryTools + + +class PursuitTreeTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.write("MEMORY.md", "# Memory\n\n## Entry {#entry}\n") + self.write("PURSUITS.md", "# Pursuits\n") + + def write(self, name: str, value: str): + (self.root / name).write_bytes(value.encode("utf-8")) + + def files(self): + return {path.name: path.read_bytes() for path in self.root.iterdir() if path.is_file()} + + def apply(self, **operation): + result = apply_operation(self.root, operation) + self.assertEqual(build_graph_manifest(self.root).errors, []) + self.assertIn("validation passed", MemoryTools(self.root).validate_memory()) + return result + + def test_snapshot_uses_logical_ancestry_across_nested_backings(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Focus\n\n- `leaf`\n\n## Parent {F#parent} → [doc:entry]\n\nParent body.\n") + self.write("PURSUIT_parent.md", "# First {#first}\n\n# Deep {F#deep}\n") + self.write("PURSUIT_deep.md", "# Leaf {#leaf}\n\nLeaf body.\n") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.root_ids, ("parent",)) + self.assertEqual(tree.items["parent"].child_ids, ("first", "deep")) + self.assertEqual(tree.items["deep"].child_ids, ("leaf",)) + self.assertEqual(tree.items["leaf"].parent_id, "deep") + self.assertEqual(tree.items["parent"].edges, (("doc", "entry"),)) + self.assertEqual(tree.items["parent"].body, "Parent body.") + self.assertEqual(tree.focus_ids, ("leaf",)) + self.assertTrue(tree.items["leaf"].focused) + self.assertEqual(tree.items["leaf"].source_path, "PURSUIT_deep.md") + self.assertEqual([item["id"] for item in tree.to_dict()["items"]], ["parent", "first", "deep", "leaf"]) + + def test_rename_patches_only_title_and_preserves_backings_and_body_bytes(self): + source = "# Pursuits\r\n\r\n## Parent {F#parent} -> [doc:entry]\r\n\r\nKeep spaces. \r\n\r\n## Other {#other}\r\n" + self.write("PURSUITS.md", source) + self.write("PURSUIT_parent.md", "# Child {#child}\n\nUnchanged child body.\n") + before = self.files() + edit = self.apply(type="rename", id="parent", title="中文 renamed") + self.assertEqual(edit.changed_paths, ("PURSUITS.md",)) + self.assertEqual((self.root / "PURSUITS.md").read_bytes(), source.replace("Parent {F#", "中文 renamed {F#").encode()) + self.assertEqual((self.root / "PURSUIT_parent.md").read_bytes(), before["PURSUIT_parent.md"]) + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["parent"].child_ids, ("child",)) + self.assertEqual(tree.items["parent"].edges, (("doc", "entry"),)) + + def test_body_edit_keeps_other_sections_and_newline_style(self): + source = "# Pursuits\r\n\r\n## Parent {#parent}\r\n\r\nOld note.\r\n\r\n### Child {#child}\r\n\r\n Child body. \r\n\r\n## Other {#other}\r\n" + self.write("PURSUITS.md", source) + self.apply(type="edit_body", id="parent", body="A **free** note.\n\n- A list item\n\n```md\n## Example\n```") + after = (self.root / "PURSUITS.md").read_bytes().decode() + self.assertEqual(after[after.index("### Child"):], source[source.index("### Child"):]) + self.assertNotIn("\n", after.replace("\r\n", "")) + self.assertEqual(load_pursuit_tree(self.root).items["parent"].child_ids, ("child",)) + + def test_create_ids_are_global_stable_and_accept_chinese_titles(self): + first = self.apply(type="create", title="Entry") + second = self.apply(type="create", title="中文方向") + third = self.apply(type="create", title="Entry") + self.assertEqual(first.selected_id, "entry-2") + self.assertTrue(second.selected_id.startswith("p-")) + self.assertEqual(third.selected_id, "entry-3") + self.apply(type="rename", id=second.selected_id, title="English now") + self.assertIn(second.selected_id, load_pursuit_tree(self.root).items) + self.assertEqual(load_pursuit_tree(self.root).root_ids, (first.selected_id, second.selected_id, third.selected_id)) + + def test_new_id_avoids_orphan_backing_and_reserved_filename(self): + self.write("PURSUIT_orphan.md", "Unrelated existing file.\n") + before = (self.root / "PURSUIT_orphan.md").read_bytes() + result = self.apply(type="create", title="Orphan") + reserved = self.apply(type="create", title="Rules") + self.assertEqual(result.selected_id, "orphan-2") + self.assertNotEqual(reserved.selected_id.casefold(), "rules") + self.assertEqual((self.root / "PURSUIT_orphan.md").read_bytes(), before) + + def test_seven_levels_create_nested_f_boundaries_with_direct_child_layout(self): + parent = None + ids = [] + for number in range(1, 8): + result = self.apply(type="create", parent_id=parent, title=f"Level {number}") + ids.append(result.selected_id) + parent = result.selected_id + tree = load_pursuit_tree(self.root) + for index, item_id in enumerate(ids): + self.assertEqual(tree.items[item_id].parent_id, ids[index - 1] if index else None) + self.assertEqual(tree.items[item_id].child_ids, (ids[index + 1],) if index < 6 else ()) + self.assertEqual(tree.items[ids[1]].anchor_kind, "F#") + self.assertEqual(tree.items[ids[4]].anchor_kind, "F#") + self.assertTrue((self.root / "PURSUIT_level-2.md").read_text(encoding="utf-8").startswith("# Level 3 {#level-3}")) + self.assertTrue((self.root / "PURSUIT_level-5.md").read_text(encoding="utf-8").startswith("# Level 6 {#level-6}")) + self.assertEqual({path.name for path in self.root.glob("PURSUIT_*.md")}, {"PURSUIT_level-2.md", "PURSUIT_level-5.md"}) + + def test_move_reorders_subtree_and_keeps_nonempty_backing_identity(self): + self.write("PURSUITS.md", "# Pursuits\n\n## A {#a}\n\n### Child {F#child}\n\nChild body.\n\n## B {#b}\n\n### Existing {#existing}\n") + self.write("PURSUIT_child.md", "# Deep {#deep}\n\nDeep content. \n") + before = (self.root / "PURSUIT_child.md").read_bytes() + self.apply(type="move", id="child", parent_id="b", after_id=None) + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["a"].child_ids, ()) + self.assertEqual(tree.items["b"].child_ids, ("child", "existing")) + self.assertEqual(tree.items["child"].child_ids, ("deep",)) + self.assertEqual((self.root / "PURSUIT_child.md").read_bytes(), before) + self.apply(type="reorder", id="child", after_id="existing") + self.assertEqual(load_pursuit_tree(self.root).items["b"].child_ids, ("existing", "child")) + + def test_move_normalizes_deep_subtree_and_removes_empty_source_backing(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Source {F#source}\n\n## Dest {#dest}\n\n### Place {#place}\n") + self.write("PURSUIT_source.md", "# Child {#child}\n\nChild body.\n\n## Grand {#grand}\n\n### Great {#great}\n") + self.apply(type="move", id="child", parent_id="place") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["source"].anchor_kind, "#") + self.assertFalse((self.root / "PURSUIT_source.md").exists()) + self.assertEqual(tree.items["place"].anchor_kind, "F#") + self.assertEqual(tree.items["child"].parent_id, "place") + self.assertEqual(tree.items["great"].parent_id, "grand") + self.assertTrue((self.root / "PURSUIT_place.md").read_text(encoding="utf-8").startswith("# Child {#child}")) + + def test_move_to_depth_three_splits_existing_child_headings_recursively(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Source {F#source}\n\n## Dest {#dest}\n") + self.write("PURSUIT_source.md", "# Child {#child}\n\n## Grand {#grand}\n\n### Great {#great}\n") + self.apply(type="move", id="child", parent_id="dest") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["child"].anchor_kind, "F#") + self.assertEqual(tree.items["grand"].parent_id, "child") + self.assertEqual(tree.items["great"].parent_id, "grand") + self.assertEqual(tree.items["source"].anchor_kind, "#") + self.assertTrue((self.root / "PURSUIT_child.md").read_text(encoding="utf-8").startswith("# Grand {#grand}")) + + def test_cycle_and_invalid_title_fail_without_any_file_change(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Parent {F#parent}\n") + self.write("PURSUIT_parent.md", "# Child {#child}\n") + for operation in ( + {"type": "move", "id": "parent", "parent_id": "child"}, + {"type": "move", "id": "parent", "parent_id": "parent"}, + {"type": "rename", "id": "parent", "title": "Break\n## Added"}, + ): + with self.subTest(operation=operation): + before = self.files() + with self.assertRaises(PursuitOperationError): + apply_operation(self.root, operation) + self.assertEqual(self.files(), before) + + def test_invalid_candidate_is_rolled_back_with_new_backing_removed(self): + self.write("PURSUITS.md", "# Pursuits\n\n## A {#a}\n\n### Place {#place}\n\n## B {#b} → [rel:place]\n") + before = self.files() + with self.assertRaisesRegex(PursuitOperationError, "containment-only"): + apply_operation(self.root, {"type": "move", "id": "b", "parent_id": "place"}) + self.assertEqual(self.files(), before) + + def test_delete_repairs_both_families_focus_and_removes_all_subtree_backings(self): + self.write("MEMORY.md", "# Memory\n\n## Entry {#entry} -> [doc:parent, dep:keep]\n\nMention parent in prose.\n\n- `fact` Child mentioned. → [rel:child]\n\n## Keep {#keep}\n") + self.write("PURSUITS.md", "# Pursuits\n\n## Focus\n\n- `parent`\n- `leaf`\n- `other`\n\n## Parent {F#parent}\n\n## Other {#other} -> [doc:child, doc:entry]\n\nMention leaf in prose.\n") + self.write("PURSUIT_parent.md", "# Child {F#child}\n") + self.write("PURSUIT_child.md", "# Leaf {#leaf}\n") + self.write("untouched.txt", "A file outside the graph.\r\n") + before = (self.root / "untouched.txt").read_bytes() + edit = self.apply(type="delete", id="parent") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.root_ids, ("other",)) + self.assertEqual(tree.focus_ids, ("other",)) + self.assertFalse((self.root / "PURSUIT_parent.md").exists()) + self.assertFalse((self.root / "PURSUIT_child.md").exists()) + self.assertEqual(tree.items["other"].edges, (("doc", "entry"),)) + self.assertIn("Mention parent in prose.", (self.root / "MEMORY.md").read_text(encoding="utf-8")) + self.assertIn("Mention leaf in prose.", (self.root / "PURSUITS.md").read_text(encoding="utf-8")) + self.assertEqual((self.root / "untouched.txt").read_bytes(), before) + self.assertEqual(len(edit.repaired_references), 5) + self.assertEqual(set(edit.changed_paths), {"MEMORY.md", "PURSUITS.md", "PURSUIT_parent.md", "PURSUIT_child.md"}) + + def test_last_child_delete_downgrades_empty_backing_but_keeps_prose_backing(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Empty {F#empty}\n\n## Prose {F#prose}\n") + self.write("PURSUIT_empty.md", "\n# Last {#last}\n") + self.write("PURSUIT_prose.md", "Keep this backing preamble.\n\n# Other {#other}\n") + self.apply(type="delete", id="last") + self.apply(type="delete", id="other") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["empty"].anchor_kind, "#") + self.assertFalse((self.root / "PURSUIT_empty.md").exists()) + self.assertEqual(tree.items["prose"].anchor_kind, "F#") + self.assertEqual((self.root / "PURSUIT_prose.md").read_text(encoding="utf-8"), "Keep this backing preamble.\n\n") + + def test_focus_order_changes_without_rewriting_bodies(self): + source = "# Pursuits\r\n\r\n## A {#a}\r\n\r\nA note. \r\n\r\n## B {#b}\r\n" + self.write("PURSUITS.md", source) + self.apply(type="set_focus", id="b", focused=True) + self.apply(type="set_focus", id="a", focused=True) + self.assertEqual(load_pursuit_tree(self.root).focus_ids, ("b", "a")) + self.assertTrue((self.root / "PURSUITS.md").read_bytes().endswith(source[source.index("## A"):].encode())) + before = self.files() + self.assertEqual(self.apply(type="set_focus", id="a", focused=True).changed_paths, ()) + self.assertEqual(self.files(), before) + self.apply(type="set_focus", id="b", focused=False) + self.assertEqual(load_pursuit_tree(self.root).focus_ids, ("a",)) + + def test_plain_group_is_visible_and_receives_id_when_edited(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Plain Group\n\nPlain body.\n\n### Child {#child}\n") + tree = load_pursuit_tree(self.root) + plain = tree.root_ids[0] + self.assertTrue(tree.items[plain].editable) + self.assertEqual(tree.items[plain].anchor_kind, "plain") + edit = self.apply(type="rename", id=plain, title="Named group") + self.assertEqual(edit.selected_id, "named-group") + self.assertEqual(load_pursuit_tree(self.root).items["child"].parent_id, "named-group") + + def test_legacy_bullet_is_read_only_preserved_on_move_and_removed_with_subtree(self): + self.write("PURSUITS.md", "# Pursuits\n\n## A {#a}\n\nA body.\n\n- `legacy` Keep this leaf. → [doc:entry]\n\n## B {#b}\n") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["a"].child_ids, ("legacy",)) + self.assertFalse(tree.items["legacy"].editable) + self.assertTrue(any("legacy" in diagnostic for diagnostic in tree.diagnostics)) + for kind in ("rename", "delete", "move", "set_focus"): + with self.subTest(kind=kind), self.assertRaises(PursuitOperationError): + apply_operation(self.root, {"type": kind, "id": "legacy", "title": "No", "focused": True}) + self.apply(type="move", id="a", parent_id="b") + self.assertIn("- `legacy` Keep this leaf. → [doc:entry]", (self.root / "PURSUITS.md").read_text(encoding="utf-8")) + self.apply(type="delete", id="a") + self.assertNotIn("legacy", load_pursuit_tree(self.root).items) + + def test_body_edit_cannot_silently_delete_or_change_legacy_graph_leaves(self): + self.write("PURSUITS.md", "# Pursuits\n\n## A {#a}\n\nA note.\n\n- `legacy` Keep. → []\n") + before = self.files() + for body in ("Replacement note.", "A note.\n\n- `legacy` Changed. → []", "## Another heading"): + with self.subTest(body=body), self.assertRaises(PursuitOperationError): + apply_operation(self.root, {"type": "edit_body", "id": "a", "body": body}) + self.assertEqual(self.files(), before) + self.apply(type="edit_body", id="a", body="Replacement note.\n\n- `legacy` Keep. → []") + self.assertIn("legacy", load_pursuit_tree(self.root).items) + + def test_legacy_next_bullets_are_raw_note_text_not_legacy_leaves(self): + body = "**State:** Old state.\n\n**Next:**\n- `research` Anything.\n- `do` Another.\n\n**Done when:** Old outcome." + self.write("PURSUITS.md", "# Pursuits\n\n## A {#a}\n\n" + body + "\n") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["a"].body, body) + self.assertEqual(tree.items["a"].child_ids, ()) + self.apply(type="edit_body", id="a", body="Only meaning remains.") + self.assertEqual(load_pursuit_tree(self.root).items["a"].body, "Only meaning remains.") + + def test_invalid_root_still_has_snapshot_and_refuses_writes(self): + self.write("PURSUITS.md", "# Pursuits\n\n## A {#a} → [rel:missing]\n") + tree = load_pursuit_tree(self.root) + self.assertIn("a", tree.items) + self.assertTrue(tree.diagnostics) + before = self.files() + with self.assertRaises(PursuitOperationError): + apply_operation(self.root, {"type": "rename", "id": "a", "title": "New"}) + self.assertEqual(self.files(), before) + + def test_focus_handles_missing_final_newline_in_existing_control_block(self): + for ending in ("## Focus", "## Focus\n\n- `a`"): + with self.subTest(ending=ending): + self.write("PURSUITS.md", "# Pursuits\n\n## A {F#a}\n\n" + ending) + self.write("PURSUIT_a.md", "# B {#b}\n") + self.apply(type="set_focus", id="b", focused=True) + expected = ("a", "b") if "`a`" in ending else ("b",) + self.assertEqual(load_pursuit_tree(self.root).focus_ids, expected) + + def test_new_child_handles_parent_document_without_final_newline(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Parent {#parent}") + result = self.apply(type="create", parent_id="parent", title="Child") + self.assertEqual(load_pursuit_tree(self.root).items[result.selected_id].parent_id, "parent") + + def test_new_focus_section_does_not_capture_existing_root_legacy_nodes(self): + self.write("PURSUITS.md", "# Pursuits\n\n- `legacy` Root leaf. → []\n\n## A {#a}\n") + self.apply(type="set_focus", id="a", focused=True) + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.root_ids, ("legacy", "a")) + self.assertEqual(tree.focus_ids, ("a",)) + + def test_unchanged_plain_group_note_is_a_noop(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Group\n\nExisting note.\n") + tree = load_pursuit_tree(self.root) + before = self.files() + self.assertEqual(self.apply(type="edit_body", id=tree.root_ids[0], body="Existing note.").changed_paths, ()) + self.assertEqual(self.files(), before) + + def test_automatic_split_preserves_legacy_leaves_and_owner_prose(self): + self.write("PURSUITS.md", "# Pursuits\r\n\r\n## Parent {#parent}\r\n\r\n### Child {#child}\r\n\r\nBefore leaf. \r\n\r\n- `legacy` Leaf. → []\r\n\r\nAfter leaf. \r\n") + self.apply(type="create", parent_id="child", title="Grandchild") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["child"].child_ids, ("legacy", "grandchild")) + source = (self.root / "PURSUITS.md").read_bytes() + backing = (self.root / "PURSUIT_child.md").read_bytes() + self.assertIn(b"Before leaf. \r\n", source) + self.assertIn(b"After leaf. \r\n", source) + self.assertIn("- `legacy` Leaf. → []\r\n".encode(), backing) + self.assertNotIn(b"\n", backing.replace(b"\r\n", b"")) + + def test_insert_after_legacy_leaf_preserves_body_and_rejects_reparenting(self): + self.write("PURSUITS.md", "# Pursuits\n\n## Parent {#parent}\n\n- `legacy` Leaf. → []\n\nOwner text after leaf.\n") + before = self.files() + with self.assertRaisesRegex(PursuitOperationError, "legacy graph leaves"): + apply_operation(self.root, {"type": "create", "parent_id": "parent", "after_id": None, "title": "First"}) + self.assertEqual(self.files(), before) + self.apply(type="create", parent_id="parent", after_id="legacy", title="Child") + tree = load_pursuit_tree(self.root) + self.assertEqual(tree.items["parent"].child_ids, ("legacy", "child")) + self.assertIn("Owner text after leaf.", tree.items["parent"].body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pursuit_web.py b/tests/test_pursuit_web.py new file mode 100644 index 0000000..fa0432d --- /dev/null +++ b/tests/test_pursuit_web.py @@ -0,0 +1,345 @@ +import subprocess +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch + +from rightmemory.web.app import create_web_app +from tests.asgi_client import ASGITestClient as TestClient + + +class PursuitWebTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempdir.cleanup) + self.root = Path(self.tempdir.name) + self._seed_root(self.root) + self.app = create_web_app(self.root, operator_token="test-operator") + self.client, self.csrf = self._login() + + def _login(self): + client = TestClient(self.app, request_timeout_seconds=30) + response = client.post("/api/login", json={"token": "test-operator"}) + self.assertEqual(response.status_code, 200) + return client, response.json()["data"]["csrf_token"] + + @staticmethod + def _git(root, *args): + result = subprocess.run( + ["git", *args], cwd=root, check=True, capture_output=True, + encoding="utf-8", errors="replace", + ) + return result.stdout.strip() + + def _seed_root(self, root): + root.mkdir(parents=True, exist_ok=True) + (root / ".gitignore").write_text(".runtime/\nother/\n", encoding="utf-8") + (root / "MEMORY.md").write_text("# Memory\n\n## Context {#context}\n\nStable context.\n", encoding="utf-8") + (root / "PURSUITS.md").write_text( + "# Pursuits\n\n## Focus\n\n- `alpha`\n\n" + "## Alpha {#alpha}\n\nA **note**.\n\n" + "### Child {#child}\n\n## Beta {#beta}\n", + encoding="utf-8", + ) + self._git(root, "init", "-q") + self._git(root, "config", "user.name", "Pursuit Web Test") + self._git(root, "config", "user.email", "pursuit-web@example.test") + self._git(root, "config", "core.autocrlf", "false") + self._git(root, "add", ".gitignore", "MEMORY.md", "PURSUITS.md") + self._git(root, "commit", "-qm", "initial map") + + def _snapshot(self, client=None): + response = (client or self.client).get("/api/pursuit-map") + self.assertEqual(response.status_code, 200, response.text) + return response.json()["data"] + + def _operation(self, operation, *, snapshot=None, client=None, csrf=None, **extra): + snapshot = snapshot or self._snapshot(client) + return (client or self.client).post( + "/api/pursuit-map/operations", + json={"expected_revision": snapshot["revision"], "operation": operation, **extra}, + headers={"x-csrf-token": csrf or self.csrf}, + ) + + def test_reads_and_mutations_require_authentication(self): + client = TestClient(self.app, request_timeout_seconds=30) + responses = [client.get("/api/pursuit-map")] + for endpoint in ("operations", "undo", "redo"): + responses.append(client.post(f"/api/pursuit-map/{endpoint}", json={})) + for response in responses: + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"]["message"], "login required") + + def test_all_mutations_require_csrf(self): + before = self._git(self.root, "rev-parse", "HEAD") + for endpoint in ("operations", "undo", "redo"): + for headers in ({}, {"x-csrf-token": "wrong"}): + with self.subTest(endpoint=endpoint, headers=headers): + response = self.client.post(f"/api/pursuit-map/{endpoint}", json={}, headers=headers) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["detail"]["message"], "invalid csrf token") + self.assertEqual(self._git(self.root, "rev-parse", "HEAD"), before) + + def test_snapshot_contains_ordered_tree_and_revision(self): + snapshot = self._snapshot() + self.assertEqual(snapshot["root_ids"], ["alpha", "beta"]) + self.assertEqual(snapshot["focus_ids"], ["alpha"]) + items = {item["id"]: item for item in snapshot["items"]} + self.assertEqual(items["alpha"]["child_ids"], ["child"]) + self.assertEqual(items["child"]["parent_id"], "alpha") + self.assertEqual(items["alpha"]["body"].strip(), "A **note**.") + self.assertTrue(items["alpha"]["focused"]) + self.assertTrue(snapshot["revision"]) + self.assertTrue(snapshot["root_key"]) + self.assertTrue(snapshot["valid"]) + self.assertTrue(snapshot["writable"], snapshot["diagnostics"]) + self.assertEqual(snapshot["git_head"], self._git(self.root, "rev-parse", "HEAD")) + + def test_each_operation_lands_one_commit_and_new_revision(self): + snapshot = self._snapshot() + operations = [ + {"type": "create", "parent_id": "alpha", "after_id": "child", "title": "New direction"}, + {"type": "rename", "title": "中文 and English"}, + {"type": "move", "parent_id": "beta", "after_id": None}, + {"type": "edit_body", "body": "Free-form **Markdown**.\n\nSome context."}, + {"type": "set_focus", "focused": True}, + {"type": "delete"}, + ] + created_id = None + for operation in operations: + if created_id is not None: + operation = {**operation, "id": created_id} + with self.subTest(operation=operation["type"]): + response = self._operation(operation, snapshot=snapshot) + self.assertEqual(response.status_code, 200, response.text) + result = response.json()["data"] + next_snapshot = result["snapshot"] + self.assertEqual(result["commit"], self._git(self.root, "rev-parse", "HEAD")) + self.assertEqual(self._git(self.root, "rev-parse", "HEAD^"), snapshot["git_head"]) + self.assertNotEqual(next_snapshot["revision"], snapshot["revision"]) + self.assertTrue(result["operation_id"]) + self.assertTrue(result["undoable"]) + self.assertIsInstance(result["repaired_references"], list) + self.assertEqual(self._git(self.root, "status", "--porcelain"), "") + if operation["type"] == "create": + created_id = result["selected_id"] + self.assertIn(created_id, {item["id"] for item in next_snapshot["items"]}) + snapshot = next_snapshot + self.assertNotIn(created_id, {item["id"] for item in snapshot["items"]}) + + def test_stale_revision_returns_conflict_and_authoritative_snapshot(self): + stale = self._snapshot() + first = self._operation({"type": "rename", "id": "alpha", "title": "Changed"}, snapshot=stale) + self.assertEqual(first.status_code, 200, first.text) + current = first.json()["data"]["snapshot"] + before = (self.root / "PURSUITS.md").read_bytes() + + conflict = self._operation({"type": "rename", "id": "beta", "title": "Stale"}, snapshot=stale) + + self.assertEqual(conflict.status_code, 409, conflict.text) + detail = conflict.json()["detail"] + self.assertEqual(detail["code"], "conflict") + self.assertTrue(detail["message"]) + self.assertIsInstance(detail["diagnostics"], list) + self.assertEqual(detail["snapshot"]["revision"], current["revision"]) + self.assertEqual((self.root / "PURSUITS.md").read_bytes(), before) + self.assertEqual(self._git(self.root, "rev-parse", "HEAD"), current["git_head"]) + + def test_undo_and_redo_add_commits_in_the_same_session(self): + original = (self.root / "PURSUITS.md").read_bytes() + applied = self._operation({"type": "rename", "id": "alpha", "title": "Renamed"}) + self.assertEqual(applied.status_code, 200, applied.text) + changed = (self.root / "PURSUITS.md").read_bytes() + result = applied.json()["data"] + undo = self.client.post( + "/api/pursuit-map/undo", + json={"expected_revision": result["snapshot"]["revision"], "commit": result["commit"]}, + headers={"x-csrf-token": self.csrf}, + ) + self.assertEqual(undo.status_code, 200, undo.text) + self.assertEqual((self.root / "PURSUITS.md").read_bytes(), original) + self.assertEqual(self._git(self.root, "rev-parse", "HEAD^"), result["commit"]) + undone = undo.json()["data"] + redo = self.client.post( + "/api/pursuit-map/redo", + json={"expected_revision": undone["snapshot"]["revision"], "commit": undone["commit"]}, + headers={"x-csrf-token": self.csrf}, + ) + self.assertEqual(redo.status_code, 200, redo.text) + self.assertEqual((self.root / "PURSUITS.md").read_bytes(), changed) + self.assertEqual(self._git(self.root, "rev-parse", "HEAD^"), undone["commit"]) + self.assertEqual(self._git(self.root, "status", "--porcelain"), "") + + def test_another_session_cannot_undo_an_editor_commit(self): + applied = self._operation({"type": "rename", "id": "alpha", "title": "Owner edit"}) + self.assertEqual(applied.status_code, 200, applied.text) + result = applied.json()["data"] + other_client, other_csrf = self._login() + response = other_client.post( + "/api/pursuit-map/undo", + json={"expected_revision": result["snapshot"]["revision"], "commit": result["commit"]}, + headers={"x-csrf-token": other_csrf}, + ) + self.assertEqual(response.status_code, 403, response.text) + self.assertEqual(response.json()["detail"]["code"], "history_forbidden") + self.assertEqual(self._git(self.root, "rev-parse", "HEAD"), result["commit"]) + + def test_client_session_identifiers_are_not_trusted(self): + snapshot = self._snapshot() + with patch("rightmemory.web.service.PursuitStore") as store: + store.return_value.apply.return_value = {"accepted": True} + response = self._operation( + {"type": "rename", "id": "alpha", "title": "Changed"}, + snapshot=snapshot, session_id="client-controlled", + ) + self.assertEqual(response.status_code, 200, response.text) + actual = store.return_value.apply.call_args.kwargs["session_id"] + self.assertTrue(actual) + self.assertNotEqual(actual, "client-controlled") + + def test_missing_revision_or_operation_never_reaches_the_store(self): + for endpoint in ("operations", "undo", "redo"): + with self.subTest(endpoint=endpoint), patch("rightmemory.web.service.PursuitStore") as store: + response = self.client.post( + f"/api/pursuit-map/{endpoint}", + json={"operation": {"type": "delete", "id": "alpha"}, "commit": "a" * 40}, + headers={"x-csrf-token": self.csrf}, + ) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["detail"]["code"], "invalid_request") + self.assertFalse(store.return_value.apply.called) + self.assertFalse(store.return_value.undo.called) + self.assertFalse(store.return_value.redo.called) + snapshot = self._snapshot() + with patch("rightmemory.web.service.PursuitStore") as store: + response = self.client.post( + "/api/pursuit-map/operations", json={"expected_revision": snapshot["revision"]}, + headers={"x-csrf-token": self.csrf}, + ) + self.assertEqual(response.status_code, 400, response.text) + store.return_value.apply.assert_not_called() + + def test_active_root_selection_scopes_reads_and_writes(self): + other_root = self.root / "other" + self._seed_root(other_root) + before = self._snapshot() + unchanged_client, _ = self._login() + selected = self.client.post( + "/api/active-root", json={"root": str(other_root)}, headers={"x-csrf-token": self.csrf}, + ) + self.assertEqual(selected.status_code, 200, selected.text) + self.csrf = selected.json()["data"]["csrf_token"] + other_snapshot = self._snapshot() + self.assertNotEqual(other_snapshot["root_key"], before["root_key"]) + edited = self._operation({"type": "rename", "id": "alpha", "title": "Other root"}) + self.assertEqual(edited.status_code, 200, edited.text) + self.assertEqual(self._snapshot(unchanged_client)["revision"], before["revision"]) + self.assertIn("Other root", (other_root / "PURSUITS.md").read_text(encoding="utf-8")) + self.assertNotIn("Other root", (self.root / "PURSUITS.md").read_text(encoding="utf-8")) + + def test_revision_from_an_identical_clone_cannot_write_the_new_active_root(self): + snapshot = self._snapshot() + original = (self.root / "PURSUITS.md").read_bytes() + other_root = self.root / "other" + self._git(self.root, "clone", "--quiet", "--no-hardlinks", str(self.root), str(other_root)) + self._git(other_root, "config", "user.name", "Pursuit Web Test") + self._git(other_root, "config", "user.email", "pursuit-web@example.test") + selected = self.client.post( + "/api/active-root", json={"root": str(other_root)}, headers={"x-csrf-token": self.csrf}, + ) + self.assertEqual(selected.status_code, 200, selected.text) + self.csrf = selected.json()["data"]["csrf_token"] + + response = self._operation({"type": "rename", "id": "alpha", "title": "Wrong root"}, snapshot=snapshot) + + self.assertEqual(response.status_code, 409, response.text) + detail = response.json()["detail"] + self.assertEqual(detail["code"], "conflict") + self.assertNotEqual(detail["snapshot"]["root_key"], snapshot["root_key"]) + for root in (self.root, other_root): + self.assertEqual(self._git(root, "rev-parse", "HEAD"), snapshot["git_head"]) + self.assertEqual((root / "PURSUITS.md").read_bytes(), original) + + def test_invalid_graph_is_readable_but_not_writable(self): + path = self.root / "PURSUITS.md" + path.write_text("# Pursuits\n\n## One {#same}\n\n## Two {#same}\n", encoding="utf-8") + self._git(self.root, "add", "PURSUITS.md") + self._git(self.root, "commit", "-qm", "invalid external edit") + before = path.read_bytes() + snapshot = self._snapshot() + self.assertFalse(snapshot["valid"]) + self.assertFalse(snapshot["writable"]) + self.assertTrue(snapshot["diagnostics"]) + response = self._operation({"type": "create", "title": "New"}, snapshot=snapshot) + self.assertEqual(response.status_code, 422, response.text) + self.assertEqual(response.json()["detail"]["code"], "invalid_root") + self.assertFalse(response.json()["detail"]["snapshot"]["writable"]) + self.assertEqual(self._git(self.root, "rev-parse", "HEAD"), snapshot["git_head"]) + self.assertEqual(path.read_bytes(), before) + + def test_dirty_root_is_read_only_and_keeps_unrelated_changes(self): + unrelated = self.root / "unrelated.txt" + unrelated.write_text("uncommitted work", encoding="utf-8") + snapshot = self._snapshot() + self.assertFalse(snapshot["writable"]) + response = self._operation({"type": "rename", "id": "alpha", "title": "Blocked"}, snapshot=snapshot) + self.assertEqual(response.status_code, 409, response.text) + self.assertEqual(response.json()["detail"]["code"], "dirty_root") + self.assertFalse(response.json()["detail"]["snapshot"]["writable"]) + self.assertEqual(unrelated.read_text(encoding="utf-8"), "uncommitted work") + self.assertEqual(self._git(self.root, "rev-parse", "HEAD"), snapshot["git_head"]) + + def test_non_git_root_is_readable_but_not_writable(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + for name in ("MEMORY.md", "PURSUITS.md"): + (root / name).write_bytes((self.root / name).read_bytes()) + client = TestClient(create_web_app(root, operator_token="test-operator"), request_timeout_seconds=30) + login = client.post("/api/login", json={"token": "test-operator"}) + snapshot = self._snapshot(client) + self.assertTrue(snapshot["valid"], snapshot["diagnostics"]) + self.assertFalse(snapshot["writable"]) + self.assertTrue(snapshot["diagnostics"]) + response = self._operation( + {"type": "create", "title": "Blocked"}, snapshot=snapshot, client=client, + csrf=login.json()["data"]["csrf_token"], + ) + self.assertEqual(response.status_code, 409, response.text) + self.assertEqual(response.json()["detail"]["code"], "read_only") + + def test_store_calls_run_off_the_event_loop(self): + caller_thread = threading.get_ident() + with patch("rightmemory.web.service.PursuitStore") as store: + store.return_value.snapshot.side_effect = lambda: {"thread": threading.get_ident()} + store.return_value.apply.side_effect = lambda *args, **kwargs: {"thread": threading.get_ident()} + read = self.client.get("/api/pursuit-map") + write = self._operation({"type": "create", "title": "New"}, snapshot={"revision": "revision"}) + self.assertNotEqual(read.json()["data"]["thread"], caller_thread) + self.assertNotEqual(write.json()["data"]["thread"], caller_thread) + + def test_pursuit_assets_are_served_without_authentication(self): + client = TestClient(self.app, request_timeout_seconds=30) + for asset, media_type in ( + ("pursuit-map.js", "text/javascript"), + ("pursuit-map.css", "text/css"), + ("pursuit-map.LICENSE.txt", "text/plain"), + ): + with self.subTest(asset=asset): + response = client.get(f"/static/{asset}") + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue(response.headers["content-type"].startswith(media_type)) + self.assertTrue(response.content) + self.assertEqual(client.get("/static/pursuit-map.js.map").status_code, 404) + + def test_task_and_reconciliation_endpoints_do_not_exist(self): + for endpoint in ("tasks", "run", "reconcile", "task-links"): + with self.subTest(endpoint=endpoint): + response = self.client.post( + f"/api/pursuit-map/{endpoint}", json={}, headers={"x-csrf-token": self.csrf}, + ) + self.assertEqual(response.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tools.py b/tests/test_tools.py index 5d9596d..3756470 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -507,11 +507,107 @@ def test_update_can_read_but_not_write_corrections(self): normal = MemoryTools(self.root, role="update") normal.read("corrections.md") - with self.assertRaisesRegex(ValueError, "PURSUIT_\\*\\.md"): + with self.assertRaises(ValueError): normal.edit_file("corrections.md", "RightMemory", "WrongMemory") - with self.assertRaisesRegex(ValueError, "PURSUIT_\\*\\.md"): + with self.assertRaises(ValueError): normal.git_add(["corrections.md"]) + def test_update_reads_pursuit_but_rejects_file_mutations(self): + contents = { + "MEMORY.md": "# Memory\n", + "PURSUITS.md": "# Pursuits\n\n## Branch {F#branch}\n", + "PURSUIT_branch.md": "# Direction {#direction}\n", + } + tools = MemoryTools(self.root, role="update") + (self.root / "MEMORY.md").write_text(contents["MEMORY.md"], encoding="utf-8", newline="") + for path in ("PURSUITS.md", "PURSUIT_branch.md"): + with self.subTest(new_path=path): + with self.assertRaises(ValueError): + tools.create_file(path, "# Replacement\n") + with self.assertRaises(ValueError): + tools.rename_file("MEMORY.md", path) + self.assertFalse((self.root / path).exists()) + (self.root / path).write_text(contents[path], encoding="utf-8", newline="") + + for path in ("PURSUITS.md", "PURSUIT_branch.md"): + with self.subTest(path=path): + self.assertIn(contents[path].splitlines()[0], tools.read_file(path)) + for mutate in ( + lambda: tools.edit_file(path, contents[path], "# Replacement\n"), + lambda: tools.delete_file(path), + lambda: tools.rename_file(path, "MEMORY_moved.md"), + ): + with self.assertRaises(ValueError): + mutate() + for path, content in contents.items(): + self.assertEqual((self.root / path).read_bytes(), content.encode("utf-8")) + self.assertFalse((self.root / "MEMORY_moved.md").exists()) + + def test_update_git_operations_reject_pursuit_paths(self): + self._git("init") + self._git("config", "user.email", "test@example.com") + self._git("config", "user.name", "Test User") + paths = ("MEMORY.md", "PURSUITS.md", "PURSUIT_branch.md") + for path in paths: + (self.root / path).write_text("# Original\n", encoding="utf-8") + self._git("add", *paths) + self._git("commit", "-m", "initial state") + initial_head = self._git("rev-parse", "HEAD") + tools = MemoryTools(self.root, role="update") + + for path in paths[1:]: + with self.subTest(path=path): + (self.root / path).write_text("# Changed\n", encoding="utf-8") + with self.assertRaises(ValueError): + tools.git_add(["MEMORY.md", path]) + self.assertEqual(self._git("diff", "--cached", "--name-only"), "") + with self.assertRaises(ValueError): + tools.git_discard([path]) + self.assertEqual((self.root / path).read_text(encoding="utf-8"), "# Changed\n") + + self._git("add", *paths[1:]) + with self.assertRaises(ValueError): + tools.git_commit("update: forbidden pursuit edit") + self.assertEqual(self._git("rev-parse", "HEAD"), initial_head) + self.assertEqual(set(self._git("diff", "--cached", "--name-only").splitlines()), set(paths[1:])) + + def test_update_can_commit_memory_and_fixed_correction_changes(self): + self._git("init") + self._git("config", "user.email", "test@example.com") + self._git("config", "user.name", "Test User") + tools = MemoryTools(self.root, role="update") + paths = ( + "MEMORY.md", + "MEMORY_detail.md", + "MEMORY_SKILL_review.md", + "MEMORY_agent-corrections-writing.md", + "MEMORY_agent-corrections-design.md", + ) + for path in paths: + tools.create_file(path, "# Original\n") + tools.read_file(path) + tools.edit_file(path, "Original", "Changed") + tools.git_add(list(paths)) + tools.git_commit("update: maintain allowed memory") + + self.assertEqual(set(self._git("ls-tree", "--name-only", "HEAD").splitlines()), set(paths)) + self.assertEqual(self._git("status", "--short"), "") + for path in paths: + self.assertEqual((self.root / path).read_text(encoding="utf-8"), "# Changed\n") + + def test_pursuit_map_tools_allow_pursuit_but_not_memory_writes(self): + tools = MemoryTools(self.root, role="pursuit-map") + tools.create_file("PURSUITS.md", "# Pursuits\n") + tools.create_file("PURSUIT_branch.md", "# Direction {#direction}\n") + for path in ( + "MEMORY.md", + "MEMORY_detail.md", + "MEMORY_agent-corrections-writing.md", + "corrections.md", + ): + with self.subTest(path=path), self.assertRaises(ValueError): + tools.create_file(path, "# Not an authorized edge repair\n") + def test_non_updater_roles_cannot_read_updater_corrections(self): (self.root / "corrections.md").write_text("private updater feedback\n", encoding="utf-8") @@ -604,6 +700,21 @@ def test_sync_reconciler_can_repair_active_memory_and_insight_logs(self): with self.assertRaises(ValueError): tools.git_add(["rightmemory.toml"]) + def test_sync_reconciler_can_modify_and_commit_pursuit_files(self): + self._git("init") + self._git("config", "user.email", "test@example.com") + self._git("config", "user.name", "Test User") + tools = MemoryTools(self.root, role="sync-reconciler") + tools.create_file("PURSUITS.md", "# Pursuits\n\n## Branch {F#branch}\n") + tools.create_file("PURSUIT_branch.md", "# Direction {#direction}\n") + tools.read_file("PURSUIT_branch.md") + tools.edit_file("PURSUIT_branch.md", "Direction", "Shared direction") + tools.git_add(["PURSUITS.md", "PURSUIT_branch.md"]) + tools.git_commit("sync: reconcile human pursuit changes") + + self.assertEqual(self._git("status", "--short"), "") + self.assertIn("Shared direction", (self.root / "PURSUIT_branch.md").read_text(encoding="utf-8")) + def test_sync_reconciler_can_repair_shared_view_registry(self): self._git("init") registry = self.root / "shared_views.toml" @@ -1535,7 +1646,7 @@ def test_validate_memory_does_not_parse_pursuit_next_actions_as_graph_nodes(self self.assertIn("validation passed", result) - def test_validate_memory_rejects_unknown_pursuit_next_action(self): + def test_validate_memory_preserves_legacy_next_bullets_without_action_validation(self): (self.root / "MEMORY.md").write_text("# Durable {#durable}\n", encoding="utf-8") (self.root / "PURSUITS.md").write_text( "# Pursuits\n\n" @@ -1547,7 +1658,7 @@ def test_validate_memory_rejects_unknown_pursuit_next_action(self): result = self._validate_complete_graph() - self.assertIn("invalid Pursuit Next action `research`", result) + self.assertIn("validation passed", result) def test_validate_memory_reserves_pursuit_rules_from_f_backing(self): (self.root / "MEMORY.md").write_text("# Durable {#durable}\n", encoding="utf-8") diff --git a/tests/test_web_cli.py b/tests/test_web_cli.py index 503b547..bb3f6a5 100644 --- a/tests/test_web_cli.py +++ b/tests/test_web_cli.py @@ -1,16 +1,24 @@ import io import os +import subprocess import tempfile +import threading import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch from rightmemory.cli import main from rightmemory.config import MEMORY_ROOT_ENV from rightmemory.web.process import ( + MANAGED_WEB_ENV, WebServiceStatus, + _wait_for_web_registration, + _wait_for_web_ready, + register_web_process, web_log_path, web_pid_path, + web_ready_path, + web_launch_path, web_settings_path, web_stop_path, ) @@ -139,3 +147,338 @@ def fake_start(memory_root, host, port, python_executable=None): self.assertEqual(result, 0) self.assertEqual(events, [("stop", root, 30), ("start", root, "127.0.0.1", 9000)]) self.assertIn("web: running pid 999", stdout.getvalue()) + + +class PursuitCliTests(unittest.TestCase): + def test_pursuit_starts_web_service_and_opens_the_map(self): + stdout = io.StringIO() + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + stopped = WebServiceStatus("stopped", None, "127.0.0.1", 9100, web_log_path(root)) + started = WebServiceStatus("running", 1234, "127.0.0.1", 9100, web_log_path(root)) + with ( + patch("rightmemory.cli.default_memory_root", return_value=root), + patch("rightmemory.cli.web_service_status", return_value=stopped), + patch("rightmemory.cli.start_web_service", return_value=started) as start, + patch("rightmemory.cli.webbrowser.open", return_value=True) as browser, + patch("sys.stdout", stdout), + ): + result = main(["pursuit"]) + self.assertEqual(result, 0) + start.assert_called_once_with(root, host="127.0.0.1", port=9100) + browser.assert_called_once_with("http://127.0.0.1:9100/#pursuit-map") + self.assertIn("http://127.0.0.1:9100/#pursuit-map", stdout.getvalue()) + + def test_pursuit_reuses_a_running_web_service(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + running = WebServiceStatus("running", 1234, "0.0.0.0", 9101, web_log_path(root)) + with ( + patch("rightmemory.cli.default_memory_root", return_value=root), + patch("rightmemory.cli.web_service_status", return_value=running), + patch("rightmemory.web.process.web_service_status", return_value=running), + patch("rightmemory.web.process._is_web_process", return_value=True), + patch("rightmemory.web.process.process_identity", return_value="test-process"), + patch("rightmemory.web.process.subprocess.Popen") as popen, + patch("rightmemory.cli.webbrowser.open", return_value=True) as browser, + patch("sys.stdout", io.StringIO()), + ): + register_web_process(root, running.pid, ready=True) + result = main(["pursuit"]) + self.assertEqual(result, 0) + popen.assert_not_called() + browser.assert_called_once_with("http://127.0.0.1:9101/#pursuit-map") + + def test_pursuit_no_open_uses_the_console_entrypoint_and_ipv6_url(self): + from rightmemory.entrypoint import main as entrypoint_main + + stdout = io.StringIO() + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + running = WebServiceStatus("running", 1234, "::", 9102, web_log_path(root)) + with ( + patch("rightmemory.cli.default_memory_root", return_value=root), + patch("rightmemory.cli.web_service_status", return_value=running), + patch("rightmemory.cli.start_web_service", return_value=running), + patch("rightmemory.cli.webbrowser.open") as browser, + patch("sys.stdout", stdout), + ): + result = entrypoint_main(["pursuit", "--no-open"]) + self.assertEqual(result, 0) + browser.assert_not_called() + self.assertIn("http://[::1]:9102/#pursuit-map", stdout.getvalue()) + + def test_pursuit_reports_url_when_browser_is_unavailable(self): + stderr = io.StringIO() + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + running = WebServiceStatus("running", 1234, "127.0.0.1", 9100, web_log_path(root)) + with ( + patch("rightmemory.cli.default_memory_root", return_value=root), + patch("rightmemory.cli.web_service_status", return_value=running), + patch("rightmemory.cli.start_web_service", return_value=running), + patch("rightmemory.cli.webbrowser.open", return_value=False), + patch("sys.stdout", io.StringIO()), + patch("sys.stderr", stderr), + ): + result = main(["pursuit"]) + self.assertEqual(result, 0) + self.assertIn("open the Pursuit Map URL", stderr.getvalue()) + + def test_pursuit_does_not_open_a_failed_service(self): + stderr = io.StringIO() + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + with ( + patch("rightmemory.cli.default_memory_root", return_value=root), + patch("rightmemory.cli.start_web_service", side_effect=RuntimeError("web service exited with code 1")), + patch("rightmemory.cli.webbrowser.open") as browser, + patch("sys.stderr", stderr), + ): + result = main(["pursuit"]) + self.assertEqual(result, 1) + browser.assert_not_called() + self.assertIn("web service exited with code 1", stderr.getvalue()) + + def test_pursuit_does_not_open_an_unknown_ephemeral_port(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + running = WebServiceStatus("running", 1234, "127.0.0.1", 0, web_log_path(root)) + with ( + patch("rightmemory.cli.default_memory_root", return_value=root), + patch("rightmemory.cli.start_web_service", return_value=running), + patch("rightmemory.cli.webbrowser.open") as browser, + patch("sys.stdout", io.StringIO()), + patch("sys.stderr", io.StringIO()), + ): + result = main(["pursuit"]) + self.assertEqual(result, 1) + browser.assert_not_called() + + def test_pursuit_does_not_accept_crud_subcommands(self): + with tempfile.TemporaryDirectory() as tempdir: + with ( + patch("rightmemory.cli.default_memory_root", return_value=Path(tempdir)), + patch("rightmemory.cli.start_web_service") as start, + patch("sys.stderr", io.StringIO()), + self.assertRaises(SystemExit) as raised, + ): + main(["pursuit", "create", "direction"]) + self.assertEqual(raised.exception.code, 2) + start.assert_not_called() + + +class WebStartupTests(unittest.TestCase): + def test_registration_accepts_a_launch_bound_redirector_child(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + process = Mock(pid=12345) + process.poll.return_value = None + with patch("rightmemory.web.process.process_identity", return_value="child-identity"): + register_web_process(root, 54321, ready=True, launch_id="this-launch") + result = _wait_for_web_registration(root, process, launch_id="this-launch") + self.assertEqual(result, 54321) + process.terminate.assert_not_called() + + def test_registration_rejects_a_different_launch_even_with_matching_identity(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + process = Mock(pid=12345) + process.poll.return_value = None + with patch("rightmemory.web.process.process_identity", return_value="child-identity"): + register_web_process(root, 54321, ready=True, launch_id="other-launch") + with patch( + "rightmemory.web.process.time.sleep", + side_effect=lambda _seconds: register_web_process(root, 54321, ready=True, launch_id="this-launch"), + ) as wait: + result = _wait_for_web_registration(root, process, launch_id="this-launch") + self.assertEqual(result, 54321) + wait.assert_called_once() + process.terminate.assert_not_called() + + def test_startup_timeout_waits_for_its_owned_child_before_clearing_files(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + process = Mock(pid=12345) + process.poll.return_value = None + process.wait.return_value = 0 + with ( + patch("rightmemory.web.process.process_identity", return_value="child-identity"), + patch("rightmemory.web.process._wait_for_exit", return_value=True) as stopped, + ): + register_web_process(root, 54321, launch_id="this-launch") + with patch("rightmemory.web.process.time.monotonic", side_effect=[0, 1]): + with self.assertRaisesRegex(RuntimeError, "did not become ready"): + _wait_for_web_registration(root, process, timeout_seconds=0, launch_id="this-launch") + self.assertFalse(web_pid_path(root).exists()) + self.assertFalse(web_launch_path(root).exists()) + stopped.assert_called_once_with(54321, 5, identity="child-identity") + process.terminate.assert_called_once() + process.wait.assert_called_once_with(timeout=5) + + def test_startup_timeout_kills_and_reaps_a_child_that_ignores_termination(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + process = Mock(pid=12345) + process.poll.return_value = None + process.wait.side_effect = [subprocess.TimeoutExpired("test child", 5), 0] + with ( + patch("rightmemory.web.process.time.monotonic", side_effect=[0, 1]), + patch("rightmemory.web.process.subprocess.run") as taskkill, + self.assertRaisesRegex(RuntimeError, "did not become ready"), + ): + _wait_for_web_registration(root, process, timeout_seconds=0, launch_id="this-launch") + process.terminate.assert_called_once() + process.kill.assert_called_once() + self.assertEqual(process.wait.call_count, 2) + if os.name == "nt": + self.assertEqual(taskkill.call_args.args[0], ["taskkill", "/PID", "12345", "/T", "/F"]) + + def test_timeout_preserves_an_unrelated_existing_registration(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + process = Mock(pid=12345) + process.poll.return_value = None + with patch("rightmemory.web.process.process_identity", return_value="other-identity"): + register_web_process(root, 54321, launch_id="other-launch") + with ( + patch("rightmemory.web.process.time.monotonic", side_effect=[0, 1]), + patch("rightmemory.web.process.subprocess.run"), + patch("rightmemory.web.process._wait_for_exit") as stopped, + patch("rightmemory.web.process.os.kill") as killed, + self.assertRaisesRegex(RuntimeError, "did not become ready"), + ): + _wait_for_web_registration(root, process, timeout_seconds=0, launch_id="this-launch") + self.assertEqual(web_pid_path(root).read_text(encoding="utf-8").strip(), "54321") + self.assertFalse(web_stop_path(root).exists()) + stopped.assert_not_called() + killed.assert_not_called() + process.wait.assert_called_once_with(timeout=5) + + def test_wait_timeout_does_not_terminate_a_reused_process(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + with patch("rightmemory.web.process.process_identity", return_value="existing-identity"): + register_web_process(root, 12345) + with ( + patch("rightmemory.web.process.time.monotonic", side_effect=[0, 1]), + patch("rightmemory.web.process._terminate_web_launcher") as terminate, + self.assertRaisesRegex(RuntimeError, "did not become ready"), + ): + _wait_for_web_ready(root, 12345, timeout_seconds=0) + self.assertTrue(web_pid_path(root).exists()) + terminate.assert_not_called() + + def test_reusing_a_starting_service_waits_without_spawning_another(self): + from rightmemory.web.process import start_web_service + + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + running = WebServiceStatus("running", 12345, "127.0.0.1", 9100, web_log_path(root)) + with ( + patch("rightmemory.web.process.web_service_status", return_value=running), + patch("rightmemory.web.process._is_web_process", return_value=True), + patch("rightmemory.web.process.process_identity", return_value="test-process"), + patch("rightmemory.web.process.subprocess.Popen") as popen, + ): + register_web_process(root, running.pid) + with patch( + "rightmemory.web.process.time.sleep", + side_effect=lambda _seconds: register_web_process(root, running.pid, ready=True), + ) as wait: + result = start_web_service(root) + self.assertEqual(result, running) + wait.assert_called_once() + popen.assert_not_called() + + def test_registration_waits_for_the_childs_ready_marker(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + process = Mock(pid=12345) + process.poll.return_value = None + with patch("rightmemory.web.process.process_identity", return_value="test-process"): + register_web_process(root, process.pid) + web_ready_path(root).write_text("54321\n", encoding="utf-8") + with patch( + "rightmemory.web.process.time.sleep", + side_effect=lambda _seconds: register_web_process(root, process.pid, ready=True), + ) as wait: + result = _wait_for_web_registration(root, process, timeout_seconds=1) + self.assertEqual(result, process.pid) + wait.assert_called_once() + process.terminate.assert_not_called() + + def test_registration_reports_child_exit_without_waiting_for_timeout(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + process = Mock(pid=12345) + process.poll.return_value = 1 + with patch("rightmemory.web.process.process_identity", return_value="test-process"): + register_web_process(root, process.pid, ready=True) + with patch("rightmemory.web.process.time.sleep") as wait: + with self.assertRaisesRegex(RuntimeError, "exited with code 1"): + _wait_for_web_registration(root, process, timeout_seconds=10) + self.assertFalse(web_ready_path(root).exists()) + self.assertFalse(web_pid_path(root).exists()) + wait.assert_not_called() + process.terminate.assert_not_called() + + def test_managed_app_announces_ready_after_server_startup(self): + from rightmemory.web.app import main as web_app_main + + ready_event = threading.Event() + registrations = [] + test = self + + class Server: + should_exit = False + started = False + + def run(server): + test.assertTrue(web_pid_path(root).exists()) + test.assertFalse(web_ready_path(root).exists()) + server.started = True + test.assertTrue(ready_event.wait(timeout=2)) + server.should_exit = True + + def record_registration(memory_root, pid, *, ready=False): + register_web_process(memory_root, pid, ready=ready) + registrations.append(ready) + if ready: + ready_event.set() + + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + with ( + patch.dict(os.environ, {MANAGED_WEB_ENV: "1"}), + patch("rightmemory.web.app.create_web_app", return_value=object()), + patch("rightmemory.web.app.uvicorn.Config"), + patch("rightmemory.web.app.uvicorn.Server", return_value=Server()), + patch("rightmemory.web.app.register_web_process", side_effect=record_registration), + ): + result = web_app_main(["--serve", "--memory-root", str(root)]) + self.assertFalse(web_ready_path(root).exists()) + self.assertFalse(web_pid_path(root).exists()) + self.assertEqual(result, 0) + self.assertEqual(registrations, [False, True]) + + def test_managed_app_never_announces_ready_after_startup_failure(self): + from rightmemory.web.app import main as web_app_main + + server = Mock(should_exit=False, started=False) + server.run.side_effect = RuntimeError("port is occupied") + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + with ( + patch.dict(os.environ, {MANAGED_WEB_ENV: "1"}), + patch("rightmemory.web.app.create_web_app", return_value=object()), + patch("rightmemory.web.app.uvicorn.Config"), + patch("rightmemory.web.app.uvicorn.Server", return_value=server), + patch("rightmemory.web.app.register_web_process", wraps=register_web_process) as register, + self.assertRaisesRegex(RuntimeError, "port is occupied"), + ): + web_app_main(["--serve", "--memory-root", str(root)]) + self.assertFalse(web_ready_path(root).exists()) + self.assertFalse(web_pid_path(root).exists()) + register.assert_called_once() + self.assertFalse(register.call_args.kwargs.get("ready", False)) diff --git a/tests/test_windows_process_integration.py b/tests/test_windows_process_integration.py index f5a20ee..e87eb66 100644 --- a/tests/test_windows_process_integration.py +++ b/tests/test_windows_process_integration.py @@ -7,8 +7,9 @@ import unittest import warnings from pathlib import Path +from unittest.mock import patch -from rightmemory.platform import process_exists +from rightmemory.platform import detached_process_kwargs, process_exists from rightmemory.watch import ( _read_pid, _watch_lock_held, @@ -18,7 +19,17 @@ stop_managed_watch, watch_pid_path, ) -from rightmemory.web.process import start_web_service, stop_web_service, web_pid_path +from rightmemory.web.process import ( + _owned_launch_process, + _wait_for_web_ready, + _wait_for_web_registration, + start_web_service, + stop_web_service, + web_launch_path, + web_log_path, + web_pid_path, + web_ready_path, +) @unittest.skipUnless(os.name == "nt", "native Windows process integration") @@ -76,6 +87,70 @@ def test_web_service_honors_cooperative_stop_request(self): finally: self._terminate_if_running(active_pid) + def test_web_startup_timeout_stops_registered_server_and_reaps_redirector(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + launchers = [] + server_pid = None + + def expire_registered_start(memory_root, process, *, launch_id): + nonlocal server_pid + launchers.append(process) + self.assertTrue(self._wait_until(lambda: _owned_launch_process(memory_root, launch_id), timeout=20)) + server_pid, _identity = _owned_launch_process(memory_root, launch_id) + return _wait_for_web_ready( + memory_root, process.pid, process=process, launch_id=launch_id, timeout_seconds=0, + ) + + try: + with patch("rightmemory.web.process._wait_for_web_registration", side_effect=expire_registered_start): + with self.assertRaisesRegex(RuntimeError, "did not become ready"): + start_web_service(root, host="127.0.0.1", port=0, python_executable=sys.executable) + self.assertIsNotNone(server_pid) + self.assertFalse(process_exists(server_pid)) + self.assertIsNotNone(launchers[0].poll()) + self.assertFalse(web_pid_path(root).exists()) + self.assertFalse(web_ready_path(root).exists()) + self.assertFalse(web_launch_path(root).exists()) + web_log_path(root).unlink() + finally: + self._terminate_if_running(server_pid) + for process in launchers: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + + def test_web_startup_timeout_reaps_unregistered_redirector_tree(self): + with tempfile.TemporaryDirectory() as tempdir: + root = Path(tempdir) + log_path = web_log_path(root) + log_path.parent.mkdir(parents=True) + child_marker = root / "started-child.pid" + code = ( + "import os, sys, time; from pathlib import Path; " + "Path(sys.argv[1]).write_text(str(os.getpid()), encoding='utf-8'); time.sleep(60)" + ) + with log_path.open("ab") as log: + process = subprocess.Popen( + [sys.executable, "-c", code, str(child_marker)], + stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, + **detached_process_kwargs(), + ) + child_pid = None + try: + self.assertTrue(self._wait_until(child_marker.exists, timeout=20)) + child_pid = int(child_marker.read_text(encoding="utf-8")) + with self.assertRaisesRegex(RuntimeError, "did not become ready"): + _wait_for_web_registration(root, process, timeout_seconds=0, launch_id="unregistered-test-launch") + self.assertFalse(process_exists(child_pid)) + self.assertIsNotNone(process.poll()) + log_path.unlink() + finally: + self._terminate_if_running(child_pid) + if process.poll() is None: + process.kill() + process.wait(timeout=5) + def _seed_sync_memory(self, root: Path) -> None: (root / "MEMORY.md").write_text("# Test Memory\n", encoding="utf-8") (root / "rightmemory.toml").write_text( From 1f09da8d6adf786ad00de4b0960dd0c2bfb5a113 Mon Sep 17 00:00:00 2001 From: RightL Date: Fri, 28 Aug 2026 09:48:03 +0800 Subject: [PATCH 2/3] Fix Windows Web shutdown checks for exited processes Check process liveness as well as creation-time identity before waiting or signaling. Windows can retain identity after exit while the parent holds the process handle. Accept a signaling error only when the owned process has exited or changed identity; preserve errors for a live process. Add three regression tests. Verified with 1,403 tests (43 skipped), frontend tests/typecheck/build, compileall, and repeated native Windows shutdown checks. --- rightmemory/web/process.py | 10 ++++++++-- tests/test_web_cli.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/rightmemory/web/process.py b/rightmemory/web/process.py index eb387af..cec8b04 100644 --- a/rightmemory/web/process.py +++ b/rightmemory/web/process.py @@ -380,12 +380,16 @@ def _cleanup_failed_web_start( def _terminate_owned_web_process(pid: int, identity: str) -> None: for sig in dict.fromkeys((signal.SIGTERM, getattr(signal, "SIGKILL", signal.SIGTERM))): - if process_identity(pid) != identity: + if not _web_process_matches(pid, identity): return try: os.kill(pid, sig) except ProcessLookupError: return + except OSError: + if not _web_process_matches(pid, identity): + return + raise if _wait_for_exit(pid, WEB_START_CLEANUP_TIMEOUT_SECONDS, identity=identity): return raise RuntimeError(f"could not stop owned Web startup process {pid}") @@ -439,7 +443,9 @@ def _wait_for_exit(pid: int, timeout_seconds: int, *, identity: str | None = Non def _web_process_matches(pid: int, identity: str | None) -> bool: if identity is not None: - return process_identity(pid) == identity + # Windows retains creation-time identity while a parent holds the + # process handle, even after exit. Identity alone does not prove liveness. + return process_exists(pid) and process_identity(pid) == identity return _is_web_process(pid) diff --git a/tests/test_web_cli.py b/tests/test_web_cli.py index bb3f6a5..85b7367 100644 --- a/tests/test_web_cli.py +++ b/tests/test_web_cli.py @@ -12,6 +12,8 @@ from rightmemory.web.process import ( MANAGED_WEB_ENV, WebServiceStatus, + _terminate_owned_web_process, + _wait_for_exit, _wait_for_web_registration, _wait_for_web_ready, register_web_process, @@ -269,6 +271,33 @@ def test_pursuit_does_not_accept_crud_subcommands(self): class WebStartupTests(unittest.TestCase): + def test_exited_process_identity_does_not_keep_shutdown_waiting(self): + with ( + patch("rightmemory.web.process.process_exists", return_value=False), + patch("rightmemory.web.process.process_identity", return_value="retained-identity"), + patch("rightmemory.web.process.os.kill") as kill, + ): + self.assertTrue(_wait_for_exit(12345, 0, identity="retained-identity")) + _terminate_owned_web_process(12345, "retained-identity") + kill.assert_not_called() + + def test_owned_process_exit_during_signal_is_not_a_shutdown_failure(self): + with ( + patch("rightmemory.web.process.process_exists", side_effect=[True, False]), + patch("rightmemory.web.process.process_identity", return_value="owned-identity"), + patch("rightmemory.web.process.os.kill", side_effect=PermissionError("process exited")), + ): + _terminate_owned_web_process(12345, "owned-identity") + + def test_owned_live_process_permission_error_is_not_hidden(self): + with ( + patch("rightmemory.web.process.process_exists", return_value=True), + patch("rightmemory.web.process.process_identity", return_value="owned-identity"), + patch("rightmemory.web.process.os.kill", side_effect=PermissionError("still running")), + self.assertRaises(PermissionError), + ): + _terminate_owned_web_process(12345, "owned-identity") + def test_registration_accepts_a_launch_bound_redirector_child(self): with tempfile.TemporaryDirectory() as tempdir: root = Path(tempdir) From 190d9f2aec4b3f3775a38e0f33a547099440914a Mon Sep 17 00:00:00 2001 From: RightL Date: Fri, 28 Aug 2026 10:38:14 +0800 Subject: [PATCH 3/3] Address Pursuit map review and Linux worker shutdown Remove the starter Focus placeholder and clear only its exact legacy body when adding the first focus. Preserve user notes and update the installer byte-preservation fixture to use the current example title. Add Ubuntu frontend install, typecheck, tests, build, and committed-bundle consistency checks. Treat Linux zombie/dead procfs states as exited while preserving process identity and PID permission checks. The original async-worker test was reproduced with a consumed stop request, idle worker state, released leader lock, and an unreaped child; it now stops without extending the timeout. Verified the complete Ubuntu suite: 1,409 tests, 13 skipped, no failures or errors. Frontend checks and all three bundle hashes match. Windows verification is also running before merge. --- .github/workflows/tests.yml | 25 ++++++++++++++++++ PURSUITS.example.md | 2 -- rightmemory/platform.py | 21 +++++++++------ rightmemory/pursuit_tree.py | 4 +++ tests/test_install.py | 4 +-- tests/test_platform.py | 51 +++++++++++++++++++++++++++++++++++++ tests/test_pursuit_tree.py | 30 ++++++++++++++++++++++ 7 files changed, 125 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7df245c..ebe3ab7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,6 +32,31 @@ jobs: python-version: "3.11" cache: pip + - name: Set up Node on Linux + if: runner.os == 'Linux' + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + cache-dependency-path: rightmemory/web/frontend/package-lock.json + + - name: Check frontend + if: runner.os == 'Linux' + working-directory: rightmemory/web/frontend + run: | + npm ci + npm run typecheck + npm test + npm run build + + - name: Verify committed frontend bundles + if: runner.os == 'Linux' + run: | + git diff --exit-code -- \ + rightmemory/web/static/pursuit-map.js \ + rightmemory/web/static/pursuit-map.css \ + rightmemory/web/static/pursuit-map.LICENSE.txt + - name: Install ripgrep on Linux if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y ripgrep diff --git a/PURSUITS.example.md b/PURSUITS.example.md index d49b76c..8b1d7d5 100644 --- a/PURSUITS.example.md +++ b/PURSUITS.example.md @@ -6,8 +6,6 @@ ## Focus -No Pursuit is focused yet. - ## Example Application {#sample-pursuit-application} Work starts from the application's repository and its current design document. diff --git a/rightmemory/platform.py b/rightmemory/platform.py index 30111e3..e827ec8 100644 --- a/rightmemory/platform.py +++ b/rightmemory/platform.py @@ -140,7 +140,10 @@ def process_exists(pid: int) -> bool: return False except PermissionError: return True - return True + # Linux retains the PID and creation time of an exited child until its + # parent reaps it. kill(pid, 0) alone would keep shutdown waiting on a zombie. + fields = _proc_stat_fields(pid) + return not fields or fields[0] not in {"Z", "X"} def process_command(pid: int) -> str | None: @@ -191,17 +194,19 @@ def _posix_process_command(pid: int) -> str | None: def _posix_process_identity(pid: int) -> str | None: + parts = _proc_stat_fields(pid) + if len(parts) <= 19: + return None + return f"proc:{parts[19]}" + + +def _proc_stat_fields(pid: int) -> list[str]: try: stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") except OSError: - return None + return [] _, separator, fields = stat.rpartition(")") - if not separator: - return None - parts = fields.split() - if len(parts) <= 19: - return None - return f"proc:{parts[19]}" + return fields.split() if separator else [] def _windows_process_exists(pid: int) -> bool: diff --git a/rightmemory/pursuit_tree.py b/rightmemory/pursuit_tree.py index b3e5bf3..a39e3fe 100644 --- a/rightmemory/pursuit_tree.py +++ b/rightmemory/pursuit_tree.py @@ -578,6 +578,10 @@ def _apply(editor: _Editor, operation: Mapping[str, object]) -> tuple[str | None f"- `{entry.id}`" + document.newline, [], document, focus) last = max((i for i, part in enumerate(focus.parts) if isinstance(part, _Entry) and part.kind == "focus"), default=-1) if last < 0: + # Older starters used this sentence for an empty Focus block. + # Remove only that entire placeholder, never user-authored notes. + if all(isinstance(part, str) for part in focus.parts) and "".join(focus.parts).strip() == "No Pursuit is focused yet.": + focus.parts = [document.newline] if not _render(focus).endswith(document.newline): focus.parts.append(document.newline) focus.parts.append(reference) diff --git a/tests/test_install.py b/tests/test_install.py index 298bafd..3ac10ec 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1091,7 +1091,7 @@ def test_rerun_preserves_managed_examples_and_user_state_byte_for_byte(self): pursuits = pursuits_path.read_text(encoding="utf-8") pursuits_path.write_text( "# User Pursuits\n\n## Continue Release {#continue-release}\n\nKeep this live intent.\n\n" - + pursuits.replace("Example Release Readiness", "Stale Release Readiness"), + + pursuits.replace("Example Application", "Stale Example Application"), encoding="utf-8", ) expected_memory = memory_path.read_bytes() @@ -1112,7 +1112,7 @@ def test_rerun_preserves_managed_examples_and_user_state_byte_for_byte(self): self.assertEqual(refreshed_memory.count(EXAMPLE_END), 1) self.assertIn("## Continue Release {#continue-release}", refreshed_pursuits) self.assertIn("Keep this live intent.", refreshed_pursuits) - self.assertIn("Stale Release Readiness", refreshed_pursuits) + self.assertIn("Stale Example Application", refreshed_pursuits) self.assertEqual(refreshed_pursuits.count(PURSUIT_EXAMPLE_START), 1) self.assertEqual(refreshed_pursuits.count(PURSUIT_EXAMPLE_END), 1) diff --git a/tests/test_platform.py b/tests/test_platform.py index 38ae366..f7635a6 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -2,7 +2,9 @@ import json import os import subprocess +import sys import tempfile +import time import unittest from pathlib import Path from unittest.mock import patch @@ -85,6 +87,55 @@ def test_posix_process_command_falls_back_to_ps_without_proc(self): self.assertEqual(command, "python -m rightmemory.cli review watch") self.assertEqual(run.call_args.args[0], ["ps", "-p", "123", "-o", "command="]) + def test_posix_process_liveness_excludes_exited_proc_states(self): + for state in ("R", "S", "Z", "X"): + with self.subTest(state=state): + stat = "123 (worker (name)) " + " ".join([state, *("0" for _ in range(18)), "456"]) + with ( + patch.object(rm_platform, "IS_WINDOWS", False), + patch.object(rm_platform.os, "kill"), + patch.object(rm_platform.Path, "read_text", return_value=stat), + ): + self.assertEqual(rm_platform.process_exists(123), state in {"R", "S"}) + self.assertEqual(rm_platform.process_identity(123), "proc:456") + + def test_posix_process_liveness_falls_back_without_proc(self): + with ( + patch.object(rm_platform, "IS_WINDOWS", False), + patch.object(rm_platform.os, "kill") as kill, + patch.object(rm_platform.Path, "read_text", side_effect=FileNotFoundError), + ): + self.assertTrue(rm_platform.process_exists(123)) + kill.assert_called_once_with(123, 0) + + def test_posix_process_liveness_preserves_pid_lookup_errors(self): + for error, expected in ((ProcessLookupError(), False), (PermissionError(), True)): + with ( + self.subTest(error=type(error).__name__), + patch.object(rm_platform, "IS_WINDOWS", False), + patch.object(rm_platform.os, "kill", side_effect=error), + ): + self.assertEqual(rm_platform.process_exists(123), expected) + + @unittest.skipUnless(sys.platform == "linux", "Linux procfs process lifecycle") + def test_linux_exited_child_is_not_live_before_parent_reaps_it(self): + process = subprocess.Popen([sys.executable, "-c", "pass"]) + try: + deadline = time.monotonic() + 5 + state = None + while time.monotonic() < deadline: + state = Path(f"/proc/{process.pid}/stat").read_text().rpartition(")")[2].split()[0] + if state == "Z": + break + time.sleep(0.01) + self.assertEqual(state, "Z") + os.kill(process.pid, 0) + self.assertFalse(rm_platform.process_exists(process.pid)) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + def test_windows_process_command_reads_cim_command_line(self): result = subprocess.CompletedProcess( ["powershell"], diff --git a/tests/test_pursuit_tree.py b/tests/test_pursuit_tree.py index 1eea07e..7f84069 100644 --- a/tests/test_pursuit_tree.py +++ b/tests/test_pursuit_tree.py @@ -206,6 +206,36 @@ def test_focus_order_changes_without_rewriting_bodies(self): self.apply(type="set_focus", id="b", focused=False) self.assertEqual(load_pursuit_tree(self.root).focus_ids, ("a",)) + def test_first_focus_removes_exact_old_starter_placeholder(self): + for newline in ("\n", "\r\n"): + with self.subTest(newline=newline): + source = ( + "# Pursuits\n\n## Focus\n\n No Pursuit is focused yet. \n\n" + "## Example Application {#sample-pursuit-application}\n\nKeep this body.\n" + ).replace("\n", newline) + self.write("PURSUITS.md", source) + self.apply(type="set_focus", id="sample-pursuit-application", focused=True) + expected = source.replace( + " No Pursuit is focused yet. ", "- `sample-pursuit-application`" + ) + self.assertEqual((self.root / "PURSUITS.md").read_bytes(), expected.encode("utf-8")) + self.assertEqual(load_pursuit_tree(self.root).focus_ids, ("sample-pursuit-application",)) + + def test_first_focus_preserves_user_body(self): + for body in ( + "Keep this attention note.", + "No Pursuit is focused yet.\n\nKeep this user note too.", + "No Pursuit is focused yet!", + ): + with self.subTest(body=body): + source = f"# Pursuits\n\n## Focus\n\n{body}\n\n## A {{#a}}\n" + self.write("PURSUITS.md", source) + self.apply(type="set_focus", id="a", focused=True) + self.assertEqual( + (self.root / "PURSUITS.md").read_bytes(), + source.replace("## A {#a}", "- `a`\n\n## A {#a}").encode("utf-8"), + ) + def test_plain_group_is_visible_and_receives_id_when_edited(self): self.write("PURSUITS.md", "# Pursuits\n\n## Plain Group\n\nPlain body.\n\n### Child {#child}\n") tree = load_pursuit_tree(self.root)