diff --git a/.changeset/centralized-workspace-storage.md b/.changeset/centralized-workspace-storage.md new file mode 100644 index 000000000..a6b01ce60 --- /dev/null +++ b/.changeset/centralized-workspace-storage.md @@ -0,0 +1,7 @@ +--- +"dsh-mnemon": patch +--- + +Add a built-in centralized workspace storage mode with live root settings, per-workspace routing and read-only inventory. Runtime, Documents, Memory Spaces and state share an isolated workspace subtree; global USER.md remains optional and scope switches preserve existing data. + +新增内置的集中工作区存储模式,提供实时根目录设置、工作区路由和只读清单。Runtime、Documents、Memory Spaces 与 state 使用独立工作区子目录,仍可共享全局 USER.md,切换范围保留旧数据。 diff --git a/.changeset/workspace-storage-layout.md b/.changeset/workspace-storage-layout.md new file mode 100644 index 000000000..efe1ef72b --- /dev/null +++ b/.changeset/workspace-storage-layout.md @@ -0,0 +1,8 @@ +--- +"dsh-mnemon": patch +"dsh-mnemon-source-memory-spaces": patch +--- + +Keep the workspace picker and alignment action visible when storage labels wrap, and constrain long Memory Space directory paths to their available column. + +存储标签换行时保持工作区选择器和对齐操作可见,并将较长的记忆空间目录路径限制在当前列宽内。 diff --git a/README.md b/README.md index a80506170..2a1108f36 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Runtime context, searchable documents and long-term evidence share a familiar Si A **memory space** is one named, Provider-backed scope for long-term evidence. It contains individual memories and can be activated independently. The Chinese product term is **记忆空间**. -Use the same data from Sidebar, conversation tools or Headless. Global, workspace and custom storage scopes are explicit. Direct retrieval does not spawn a Mnemon task Agent; Agent Query, semantic writes and maintenance may use the configured model. [Workflows and costs](https://github.com/omdsh-dev/dsh-mnemon/blob/main/docs/en/reference/workflows.md). +Use the same data from Sidebar, conversation tools or Headless. Global, workspace, centralized workspace and custom storage scopes are explicit. Direct retrieval does not spawn a Mnemon task Agent; Agent Query, semantic writes and maintenance may use the configured model. [Workflows and costs](https://github.com/omdsh-dev/dsh-mnemon/blob/main/docs/en/reference/workflows.md). ## Start with the default installation diff --git a/README.zh-CN.md b/README.zh-CN.md index acbef5595..48785802d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,7 +40,7 @@ **记忆空间(memory space)** 是由 Provider 承载、可以独立命名和激活的长期证据范围,其中包含多条具体记忆。 -Sidebar、对话工具与 Headless 使用同一套数据。全局、工作区与自定义范围明确可选。直接检索不创建 Mnemon 任务 Agent;Agent 查询、语义写入和整理可能使用已配置的模型。[流程与调用开销](https://github.com/omdsh-dev/dsh-mnemon/blob/main/docs/zh-CN/reference/workflows.md)。 +Sidebar、对话工具与 Headless 使用同一套数据。全局、工作区、集中工作区与自定义范围明确可选。直接检索不创建 Mnemon 任务 Agent;Agent 查询、语义写入和整理可能使用已配置的模型。[流程与调用开销](https://github.com/omdsh-dev/dsh-mnemon/blob/main/docs/zh-CN/reference/workflows.md)。 ## 从默认组合开始 diff --git a/docs/en/development/README.md b/docs/en/development/README.md index dece8b164..2e4731c9f 100644 --- a/docs/en/development/README.md +++ b/docs/en/development/README.md @@ -111,7 +111,7 @@ Check Sidebar without a session, all primary/secondary tabs, Runtime add/edit/re For an embedded Electron Host, pass `pnpm e2e:serve --electron=/absolute/path/to/electron` (on macOS, use `Electron.app/Contents/MacOS/Electron`). Supply a separately installed test Electron executable and an isolated npm prefix through `MNEMON_CLI_PATH` and `npm_config_prefix`. The fixture runs the published DSH Web stack in Electron's main process with no `ELECTRON_RUN_AS_NODE` on the Host. It exposes Node internals for the published Cordis loader, without rebuilding or modifying DSH packages. Stop with Ctrl-C as usual. -Also switch `displayMode` live: Sidebar and Builtin must never mount together. Both use the same Source pages; Builtin follows its owning session for global/workspace/custom reads, writes and tasks, hides scope controls, and clears stale data and editors when the session changes. Check legacy `buildin` normalization and the collapsed icon under the native Sidebar skin as well as supported layout plugins. +Also switch `displayMode` live: Sidebar and Builtin must never mount together. Both use the same Source pages; Builtin follows its owning session for global/workspace/workspaces/custom reads, writes and tasks, hides scope controls, and clears stale data and editors when the session changes. Check legacy `buildin` normalization and the collapsed icon under the native Sidebar skin as well as supported layout plugins. The [2026-09-04 main-rebase verification](../../pr-assets/main-rebase-20260904/README.md) records the exact v0.4.7/DSH rc.1 revisions, full registry and source-overlay suites, independent artifacts, plugin composition persistence and real shared-placement checks, including their limits. diff --git a/docs/en/development/architecture.md b/docs/en/development/architecture.md index 0df6ff712..2bac52897 100644 --- a/docs/en/development/architecture.md +++ b/docs/en/development/architecture.md @@ -35,6 +35,12 @@ Like a Spring Boot starter, the default distribution chooses dependencies and ex `ctx.mnemonMemory` is a real restricted service object, not the engine cast to a narrower type. It exposes one registration primitive, used through `installMemory`; Host execution stays internal. Provider modules follow the same principle inside their own Source, with only a bound `host.install` capability. Public test fixtures exercise these protocols without handing out their private owners. +## Built-in storage scope + +Storage layout is part of the root package's Host infrastructure. The same resolver handles `global`, `workspace`, `custom` and `workspaces`; Core carries the selected operation scope and enforces View authority. Filesystem canonicalization, environment/home defaults and workspace directory hashes stay in Host code, without a storage contribution API or a separate package. + +The Host passes the resolved directory to each default Source. Sources continue to own their formats, transactions and Provider state; a layout change does not move their data. The opt-in global USER.md uses a separate global root while project data stays under the chosen workspace root. Settings and read-only inventory use the same layout resolver. + ## Default plugin combination | Plugin | Memory authority | Default View contribution | diff --git a/docs/en/guides/capabilities.md b/docs/en/guides/capabilities.md index b9b4a678a..c65f14e9c 100644 --- a/docs/en/guides/capabilities.md +++ b/docs/en/guides/capabilities.md @@ -69,11 +69,12 @@ Tasks follow the DSH new-session model route by default. **Settings → Memory S Bounded workers may still perform structured judgment internally, but that is an implementation detail. The UI and product documentation consistently call the user-visible unit an **independent task Agent**. -## Global, workspace, and custom scope +## Storage scope - **Global** uses `~/.mnemon`, suitable for a control plane shared by local workspaces and Agents. - **Workspace** uses `/.mnemon`; local data planes such as Mnemon, Holographic, and ByteRover can follow it automatically. - **Custom** is effectively a global scope at an explicit path, useful for team conventions and isolated demo roots. +- **Centralized workspaces** (`workspaces`) is built into the Host: one fixed root, independent workspace subdirectories, and optional global USER.md. Scope switches preserve every old root. Remote Provider workspaces, users, banks, projects, containers, and URIs are their own namespaces. Switching the DSH workspace never silently rewrites them. The workbench may inspect a chosen directory; an independent task Agent always writes according to its effective workspace and saved scope rules. diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index 7cfd24497..ebdd9cbaf 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -163,6 +163,9 @@ By default, open the dedicated workbench from Memory System in the DSH sidebar. | **Global** (default) | `MNEMON_DATA_DIR` or `~/.mnemon` | Sharing one memory set across workspaces | | **Workspace** | `/.mnemon` | Project isolation with cross-workspace inspection in the workbench | | **Custom** | `dataDir` | A dedicated disk, mounted volume, or explicit directory | +| **Centralized workspaces** | `/workspaces//` | Central management with project isolation | + +For centralized project isolation, select `storageScope: workspaces` and optionally set `dataDir`; data is stored in `/workspaces//`. The directory setting appears alongside the scope selector. Existing roots are retained when switching modes. Save initializes a candidate runtime graph before atomically switching the Host. The page clears stale state and reloads automatically—no browser refresh is needed. Changing scope never migrates, merges, or deletes old data. diff --git a/docs/en/guides/operations.md b/docs/en/guides/operations.md index 459c44f3f..875b498ec 100644 --- a/docs/en/guides/operations.md +++ b/docs/en/guides/operations.md @@ -98,7 +98,7 @@ Generate an inventory or checksums and rehearse recovery in isolation. A normal ## Changing storage scope -Saving `global` / `workspace` / `custom` initializes a new runtime graph before switching atomically. The page reloads automatically, but **data is not migrated**: +Saving `global` / `workspace` / `custom` / `workspaces` initializes a new runtime graph before switching atomically. The page reloads automatically, but **data is not migrated**: ```text old scope -- save --> new empty or existing root @@ -110,6 +110,9 @@ no automatic delete Recommended migration: export from the old scope → switch and confirm the new root → import → verify. In Workspace mode, confirm both inspection and execution targets. +With `workspaces`, back up the complete central directory for every workspace, or export a Pack for the selected workspace only. A renamed/moved workspace receives a new path hash; restoring its old data is an explicit operator action. + + Existing turns and delegated child activations may still use the old runtime. Wait for them to finish or cancel them before moving or retiring its data. Parent completion alone does not release an asynchronous child's delegation; a newly created or cold-resumed activation captures its own authorized generation. diff --git a/docs/en/guides/ui-guide.md b/docs/en/guides/ui-guide.md index 51211a72f..d7cc8826a 100644 --- a/docs/en/guides/ui-guide.md +++ b/docs/en/guides/ui-guide.md @@ -16,7 +16,7 @@ Older media remain available with their original version labels in [historical e The Memory System sidebar entry always opens its workspace, including after visiting Task Board or SSH. Clicking it again keeps the current page open; use Back to conversation to close it. -With `displayMode: builtin`, open Memory System from the conversation's tabs instead; the Sidebar entry is absent. The header omits storage-mode and workspace-selection controls because the Host uses the owning session's global, workspace or custom scope. All Source pages and dialogs below are shared, and conversation shortcuts open the matching tab. See [scope mapping](../reference/configuration.md#entry-placement-displaymode-and-tabenabled). +With `displayMode: builtin`, open Memory System from the conversation's tabs instead; the Sidebar entry is absent. The header omits storage-mode and workspace-selection controls because the Host uses the owning session's global, workspace, centralized workspaces or custom scope. All Source pages and dialogs below are shared, and conversation shortcuts open the matching tab. See [scope mapping](../reference/configuration.md#entry-placement-displaymode-and-tabenabled). Primary pages remain **Status, Runtime, Documents, Memory Spaces**. Memory Spaces adds **Overview, Recall, Content, Entities**, with **Remember** and **Distillation strategy** at the top right. A generated View is an internal per-turn runtime artifact, not a navigation page; Status does not own plugin discovery or installation. @@ -172,6 +172,8 @@ Settings centralizes stable user choices and reusable **service configuration**: - API Keys use a conventional password field whose eye button toggles visible/hidden; there is no clear-credential checkbox, dedicated Remove row, or saved-secret caption; - the three enhancement switches apply immediately; the footer Save action persists all other changes without waiting for discovery or recall. Health belongs on Status and instances belong on Overview; - global / workspace / custom tags show effective scope; Providers with the same scope semantics reuse Mnemon's configuration framework. +- Choose **Settings → Memory System → Memory scope → Centralized · isolated by workspace** to collect project-isolated memory in one directory. Its optional **Central root directory** field is in the same section; leave it empty for `MNEMON_DATA_DIR` or `~/.mnemon`. The independent **Global user profile** option remains available. + - User profile scope is independent: **Global user profile** combines global USER.md with workspace/custom MEMORY.md without moving either source. Each default layer has one master switch. “On” permits on-demand use; it does not force Recall on every turn. diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index 5cbb6d7ff..107e5b55a 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -12,13 +12,13 @@ $DSH_HOME/settings.yaml The default is commonly `~/.dsh/settings.yaml`. All current settings are marked `live`; after Save, the Host initializes a candidate runtime graph and then switches to it atomically. -The Web settings page edits `storageScope`, the independent `runtimeUserScope`, `dataDir`, Mnemon Native's Ollama embedding override, one master switch for each of the three memory Sources, the background task Agent model route, and the Turn memory and Save-to-memory switches under `mnemon-ui`. Global and Workspace define the scope of the complete memory system; the USER.md profile may explicitly remain global while project memory follows that scope. Mnemon Native owns its Custom data location, embedding runtime, and ZIP backup/migration controls. Each external provider has a collapsible service configuration for reusable endpoints, credentials, or executables. Enabling or saving it discovers the provider's existing namespaces and maps them into Memory Spaces → Overview; disabling it removes those local mappings without deleting provider data. Other advanced settings must be changed directly in YAML. +The Web settings page edits `storageScope`, the independent `runtimeUserScope`, `dataDir`, Mnemon Native's Ollama embedding override, one master switch for each of the three memory Sources, the background task Agent model route, and the Turn memory and Save-to-memory switches under `mnemon-ui`. The scope selector applies to the complete memory system. Centralized workspaces exposes its optional root beside that selector; the USER.md profile may explicitly remain global while project memory follows the selected scope. Mnemon Native owns its Custom data location, embedding runtime, and ZIP backup/migration controls. Each external provider has a collapsible service configuration for reusable endpoints, credentials, or executables. Enabling or saving it discovers the provider's existing namespaces and maps them into Memory Spaces → Overview; disabling it removes those local mappings without deleting provider data. Other advanced settings must be changed directly in YAML. ## Complete Example ```yaml mnemon: - storageScope: global # global | workspace | custom + storageScope: global # global | workspace | custom | workspaces runtimeUserScope: storage # storage | global # dataDir: ~/mnemon-data # required for custom # cliPath: /opt/homebrew/bin/mnemon @@ -63,9 +63,9 @@ mnemon: | Setting | Default | Range | Implementation Semantics | |---|---:|---|---| -| `storageScope` | `global` | `global` / `workspace` / `custom` | Controls the root for Runtime, Documents, Memory Spaces, and reserved state as one unit | +| `storageScope` | `global` | `global` / `workspace` / `custom` / `workspaces` | Controls the root for Runtime, Documents, Memory Spaces, and reserved state as one unit | | `runtimeUserScope` | `storage` | `storage` / `global` | Keeps USER.md in the selected storage root, or overlays the global USER.md while project MEMORY.md and the other layers stay selected-scope | -| `dataDir` | unset | absolute path, `~`, or `~/...` | Required for `custom`; legacy configurations that set only this option automatically resolve to `custom` | +| `dataDir` | unset | absolute path, `~`, or `~/...` | Required for `custom`; optional central root for `workspaces`; legacy configurations that set only this option automatically resolve to `custom` | | `cliPath` | auto-discovered | executable path | Explicitly selects the Mnemon CLI | | `store` | unset | `[A-Za-z0-9][A-Za-z0-9_-]*` | Compatibility discovery/preference hint for legacy Stores; semantic operations are routed through Memory Spaces | | `timeoutMs` | `10000` | 100–120000 ms | Hard timeout for a single CLI call | @@ -208,6 +208,21 @@ Each turn then projects `USER.md` from the global root (`MNEMON_DATA_DIR` when s Changing this setting never copies, merges, or deletes entries. Switching back to `runtimeUserScope: storage` reveals the selected root's original USER.md again. A Mnemon Pack still represents one selected storage root, so a workspace Pack does not silently include the separate global USER.md; back up the global root separately when that profile is important. +### `workspaces` + +This built-in mode is available with the Starter; no additional plugin is required. Select **Centralized · isolated by workspace** in Memory scope; configure its optional **Central root directory** in the same section. + +```yaml +mnemon: + storageScope: workspaces + dataDir: ~/central-memory # optional; otherwise MNEMON_DATA_DIR or ~/.mnemon + runtimeUserScope: global # optional; share only USER.md +``` + +All four areas (`runtime`, `data`, `documents`, `state`) live under `/workspaces//`. Existing symlink aliases resolve to the same ID; different workspace paths remain isolated. A move or rename selects a new ID, with no automatic migration. Sidebar inspection follows the selected registered workspace; Builtin and Headless follow the owning session cwd. Global USER.md still uses `MNEMON_DATA_DIR` or `~/.mnemon`, even when the central root is customized. + +Changing scopes never migrates, merges or deletes an old root. A ZIP Pack still contains only the selected workspace root; back up the whole central directory to preserve all workspaces. Remote Provider namespaces retain their own sharing semantics. + ### `custom` ```yaml @@ -345,6 +360,7 @@ Builtin omits the header's storage-mode badge, workspace picker, and alignment c | `global` | Shared `MNEMON_DATA_DIR` or `~/.mnemon`, regardless of the session workspace | | `workspace` | The current session's `/.mnemon`; switching conversations follows their respective workspaces | | `custom` | Configured `dataDir`, regardless of the session workspace | +| `workspaces` | The current session’s subtree under `/workspaces//` | The existing `runtimeUserScope: global` exception still keeps USER.md global. Changing placement does not change scope, migrate memory data, or revive the old builtin navigation. Settings RPC applies entry changes live. diff --git a/docs/en/reference/storage-model.md b/docs/en/reference/storage-model.md index d8ee0407b..7e5770cd8 100644 --- a/docs/en/reference/storage-model.md +++ b/docs/en/reference/storage-model.md @@ -59,6 +59,9 @@ Use `global` for a common local root, `custom` for an explicitly agreed root, or `storageScope` determines the entire root, not just the Mnemon databases. The `workspace` scope resolves an independent `/.mnemon` for every registered DSH workspace. The opt-in `runtimeUserScope=global` is the sole split-root exception: Runtime reads USER.md from the global root while MEMORY.md and every other component remain under the selected root. Workbench tasks use the inspected workspace; conversation tools and lifecycle hooks use their owning session's cwd and pinned View. `state/memory-providers.json` stores third-party endpoints, target URIs, identities, and optional credentials. Its mode is `0600`; the Host returns configured field names, never saved credential values. +The `workspaces` layout keeps all four areas under `/workspaces//`; Host path resolution never creates files or changes old roots. Only explicit `runtimeUserScope: global` places USER.md outside that workspace subtree. + + ## Runtime Memory ### Semantics @@ -133,7 +136,7 @@ User profiles, ordinary conversation, temporary progress, raw large logs, and se The physical sharing scope of Documents is determined by `storageScope`: -- `workspace`: normally isolated with the project; +- `workspace` / `workspaces`: normally isolated with the project; - `global` / `custom`: multiple workspaces may share the same `documents/index.json`. Therefore, “Project Documents” describes the content type and does not guarantee physical isolation by workspace. The current session workspace constrains only `sourcePaths` on new writes. diff --git a/docs/pr-assets/README.md b/docs/pr-assets/README.md index e6f3989b5..714857255 100644 --- a/docs/pr-assets/README.md +++ b/docs/pr-assets/README.md @@ -12,6 +12,7 @@ For new evaluations, commit the reusable harness, workload generator, concise bi | Record / 记录 | Scope / 范围 | |---|---| +| [Built-in centralized workspaces — issue #189](./issue-189-core-workspaces/README.md) / [中文](./issue-189-core-workspaces/README.zh-CN.md) | Core/Host storage scope, Source isolation, real WebUI and restart / 内置存储范围、Source 隔离、真实 WebUI 与重启 | | [Automatic memory quality — 2026-09-09](./runtime-memory-quality-flash-20260909/README.md) / [中文](./runtime-memory-quality-flash-20260909/README.zh-CN.md) | Four concurrent developer sessions with real Flash; current facts, corrections, clutter and fresh recall / 四会话自动记忆质量验收 | | [Runtime capacity — 2026-09-09](./runtime-capacity-flash-20260909/README.md) / [中文](./runtime-capacity-flash-20260909/README.zh-CN.md) | Real Flash, exact archival, concurrent and session-free writes / 真实 Flash 归档与并发写入 | | [Creation-time ordering](./issue-202-created-order/README.md) | Issue #202 Runtime/Documents ordering, real WebUI writes and retained screenshots | diff --git a/docs/pr-assets/issue-189-core-workspaces/01-settings-zh-dark.png b/docs/pr-assets/issue-189-core-workspaces/01-settings-zh-dark.png new file mode 100644 index 000000000..2221b670d Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/01-settings-zh-dark.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/02-workspace-a-runtime.png b/docs/pr-assets/issue-189-core-workspaces/02-workspace-a-runtime.png new file mode 100644 index 000000000..f2dc1db4f Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/02-workspace-a-runtime.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/03-workspace-a-document.png b/docs/pr-assets/issue-189-core-workspaces/03-workspace-a-document.png new file mode 100644 index 000000000..8fde681da Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/03-workspace-a-document.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/04-workspace-a-providers.png b/docs/pr-assets/issue-189-core-workspaces/04-workspace-a-providers.png new file mode 100644 index 000000000..7108887c4 Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/04-workspace-a-providers.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/05-workspace-b-isolation.png b/docs/pr-assets/issue-189-core-workspaces/05-workspace-b-isolation.png new file mode 100644 index 000000000..73cdbf6fd Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/05-workspace-b-isolation.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/06-settings-en-light.png b/docs/pr-assets/issue-189-core-workspaces/06-settings-en-light.png new file mode 100644 index 000000000..50a046f9d Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/06-settings-en-light.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/07-builtin-b-after-restart.png b/docs/pr-assets/issue-189-core-workspaces/07-builtin-b-after-restart.png new file mode 100644 index 000000000..afb316010 Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/07-builtin-b-after-restart.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/08-workspace-alignment.png b/docs/pr-assets/issue-189-core-workspaces/08-workspace-alignment.png new file mode 100644 index 000000000..333cd2288 Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/08-workspace-alignment.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/09-sidebar-390px.png b/docs/pr-assets/issue-189-core-workspaces/09-sidebar-390px.png new file mode 100644 index 000000000..15dab26a9 Binary files /dev/null and b/docs/pr-assets/issue-189-core-workspaces/09-sidebar-390px.png differ diff --git a/docs/pr-assets/issue-189-core-workspaces/README.md b/docs/pr-assets/issue-189-core-workspaces/README.md new file mode 100644 index 000000000..0cb28e1a8 --- /dev/null +++ b/docs/pr-assets/issue-189-core-workspaces/README.md @@ -0,0 +1,65 @@ +# Built-in centralized workspaces — issue #189 + +[中文](./README.zh-CN.md) · [Evidence index](../README.md) + +Tested on 2026-09-09 with published DSH 0.1.2-rc.1, Node 22.19.0, pnpm 10.13.1 and Mnemon Native 0.2.7 on macOS. This is a fresh implementation based on `main` at `820b0b092fe68eb7ce6fbf1eaa12e9cf39087e31`, replacing the separate storage-plugin approach in PR #215. The original checkout and old branch were retained. + +The functional run used `2314d6a44ee21b74f1e3bb22a0533d90285c5eea`; final layout verification and the complete automated checks used `51446b51ed636eace6f6ca4d206969561ada194d`. Screenshots 01–03 and 05–07 record the functional revision; 04, 08 and 09 record the final layout. None were copied from the old plugin PR. + +## Implementation boundary + +The root package's Host resolves all four layouts and canonical workspace identities; Core carries the operation scope and retains View authority. Existing Sources receive their directory through the current configuration contract. There is no new package, dependency, storage contribution API or SDK export. The existing Memory Spaces package has a presentation-only fix for long paths. + +## Automated verification + +```bash +MNEMON_NATIVE_TEST_CLI=/opt/homebrew/bin/mnemon npx --yes --package=node@22.19.0 --package=pnpm@10.13.1 --call 'pnpm run verify' +npx --yes --package=node@22.19.0 --package=pnpm@10.13.1 --call 'node scripts/verify-plugin-artifacts.mjs --skip-build' +CHANGESET_BASE_REVISION=820b0b092fe68eb7ce6fbf1eaa12e9cf39087e31 npx --yes --package=node@22.19.0 --package=pnpm@10.13.1 --call 'pnpm run release:intent' +``` + +| Check | Result | +|---|---| +| Root tests | 852 passed; 5 opt-in Flash checks skipped | +| Independent plugin tests in `verify` | 324 passed; 1 Windows-only smoke check skipped | +| Deterministic build, TypeScript, package exports and package lint | Passed | +| Real Headless profile | 39 tools; 8 representative Mnemon tools; restart and legacy-disable checks passed | +| Real Native CLI | Temporary View write, recall and deletion passed | +| Packed artifacts | 16 independent plugin repositories and 17 tarballs passed, including root-only Starter installation into real DSH | + +Coverage includes all four storage scopes, legacy `dataDir`, registry rejection, session-owned Headless/Builtin routing, real Source writes to all four areas, global versus storage USER.md, symlink aliases, missing path descendants, Unicode, same-named directories, rename behavior and non-destructive switching. The latest main's 23 capacity-workflow tests also passed. + +The initial package-size check exposed a tight pre-existing ceiling. A separate build of the base revision measured 1,249,360 unpacked bytes; the final package is 1,256,247 bytes (+6,887), still 46 files. Growth is confined to Host/UI, public scope types and README copy. The bounded ceiling is now 1,260,000 bytes; no Source or Provider implementation was bundled into the root. + +## Real WebUI workflow + +Run `pnpm e2e:serve` after building. The existing harness uses a disposable Profile, global data root, two temporary workspaces, an explicit temporary central root and a loopback-only scripted model. The browser exercised the real DSH settings, RPC and Source stack. + +1. Select centralized workspace storage and global USER.md. A relative root blocks Save; an absolute root saves and takes effect live. +2. In A, add a project fact and a global preference, create a Document, enable Holographic and create a real Native space (88 KB database). +3. In B, confirm zero A project facts, Documents or Provider mappings. Only the global preference is visible. Add B's distinct project fact. +4. Clear the root and save: the workspace uses the default global-root subtree, with the global preference still visible. Restore the custom root: the original facts return. Five control-file SHA-256 values remain identical across these switches and Sidebar/Builtin changes. +5. Create real A/B DSH sessions with the scripted model. Builtin follows each owning session and omits the workspace picker. Restart the Host: both facts, the global preference, A's Document and its Provider mappings survive. +6. Inspect A while the active conversation belongs to B. The difference indicator appears; Align returns to B. Test Chinese Dark, English Light and a 390×844 Sidebar. + +Disk assertions found `runtime`, `data`, `documents` and `state` under A's single hashed subtree, and no `.mnemon` directory in either project. Desktop Provider canvas measurements were 992/992 pixels (client/scroll width); narrow Runtime and Memory Spaces were 326/326. Final browser console inspection returned no errors. + +Screenshot review caught and fixed two layout defects: long directory paths caused horizontal overflow, and the English alignment notice squeezed out the workspace selector. The final screenshots show constrained directory text and wrapping header controls; the existing presentation fingerprint was updated after visual verification. + +## Screenshots + +| Evidence | Shows | +|---|---| +| [01 — Chinese Dark settings](./01-settings-zh-dark.png) | Scope, optional root and independent global profile | +| [02 — A Runtime](./02-workspace-a-runtime.png) | A project fact plus global preference | +| [03 — A Document](./03-workspace-a-document.png) | Successful managed Document creation | +| [04 — A Providers](./04-workspace-a-providers.png) | Real Native database and Holographic mapping; corrected long-path layout | +| [05 — B isolation](./05-workspace-b-isolation.png) | Global preference present; A project fact absent | +| [06 — English Light settings](./06-settings-en-light.png) | Full centralized label and root control | +| [07 — Builtin after restart](./07-builtin-b-after-restart.png) | B's recovered fact in its owning session | +| [08 — Workspace alignment](./08-workspace-alignment.png) | A inspection versus B execution, with visible selector and alignment action | +| [09 — 390px Sidebar](./09-sidebar-390px.png) | Narrow Memory Spaces, long path and alignment controls | + +## Limits + +This run validates local persistence and UI/transport behavior, not live model distillation quality or remote Provider accounts. Five real-Flash checks were not enabled; the Windows-only binary smoke check requires Windows. Moving or renaming a workspace selects a new path-derived ID; old data is preserved without migration. Remote Provider namespaces keep their own sharing semantics. Test data and browser tabs were removed after verification; screenshots contain only synthetic content and temporary paths. diff --git a/docs/pr-assets/issue-189-core-workspaces/README.zh-CN.md b/docs/pr-assets/issue-189-core-workspaces/README.zh-CN.md new file mode 100644 index 000000000..309750d97 --- /dev/null +++ b/docs/pr-assets/issue-189-core-workspaces/README.zh-CN.md @@ -0,0 +1,61 @@ +# 内置集中工作区存储 — issue #189 + +[English](./README.md) · [证据索引](../README.md) + +2026-09-09 在 macOS 上使用正式发布的 DSH 0.1.2-rc.1、Node 22.19.0、pnpm 10.13.1 和 Mnemon Native 0.2.7 验证。新实现从 `main` 的 `820b0b092fe68eb7ce6fbf1eaa12e9cf39087e31` 分出,替代 PR #215 的独立存储插件方案。原工作目录和旧分支均保留。 + +功能验收 revision 为 `2314d6a44ee21b74f1e3bb22a0533d90285c5eea`,最终布局和完整自动化验证对应 `51446b51ed636eace6f6ca4d206969561ada194d`。截图 01–03、05–07 对应功能版本,04、08、09 对应最终布局;均为本次重新采集,没有复用旧插件 PR 的截图。 + +## 实现边界 + +根包 Host 统一解析四种布局和规范化工作区标识;Core 传递操作范围并保持 View 权限边界。现有 Source 通过原配置契约接收目录。没有新增包、依赖、存储贡献 API 或 SDK 导出。现有 Memory Spaces 包仅调整了长路径展示样式。 + +## 自动化验证 + +精确复现命令见[英文记录](./README.md#automated-verification),使用固定版本的 Node/pnpm 环境。 + +| 检查 | 结果 | +|---|---| +| 根包测试 | 852 通过;5 个 opt-in Flash 检查跳过 | +| `verify` 中的独立插件测试 | 324 通过;1 个 Windows 专属冒烟测试跳过 | +| 确定性构建、TypeScript、包出口与发布包检查 | 通过 | +| 真实 Headless Profile | 39 个工具、8 个代表性 Mnemon 工具;重启与旧 Entry 停用检查通过 | +| 真实 Native CLI | 临时 View 写入、召回、删除通过 | +| 独立制品 | 16 个独立插件仓库、17 个 tarball 通过;真实 DSH 仅安装根包 Starter 后可正常激活 | + +覆盖四种存储模式、旧 `dataDir` 配置、registry 拒绝路径、Headless/Builtin 会话归属、真实 Source 的四区域写入、USER.md 两种范围、符号链接别名、尚未创建的子路径、Unicode、同名目录、重命名和无破坏切换。最新 main 的 23 个容量工作流测试也全部通过。 + +最初发布包检查触发现有体积上限。单独构建 base revision 后,基线为 1,249,360 字节,最终为 1,256,247 字节,增加 6,887 字节,文件数仍为 46。增量仅来自 Host/UI、公开范围类型和 README 文案;没有打入 Source 或 Provider 实现。上限小幅调整为 1,260,000 字节。 + +## 真实 WebUI 验收 + +构建后运行已有的 `pnpm e2e:serve`。夹具使用临时 Profile、全局根、两个临时工作区、自定义集中根和仅监听 loopback 的固定回复模型;操作经过真实 DSH 设置、RPC 和 Source。 + +1. 选择集中工作区存储和全局 USER.md;相对根目录无法保存,绝对目录保存后实时生效。 +2. 在 A 写入项目事实和全局偏好,创建档案,启用 Holographic,并通过真实 Native CLI 创建 88 KB 数据库。 +3. B 看不到 A 的项目事实、档案或 Provider 映射,只能看到全局偏好;另写入 B 的独立项目事实。 +4. 清空集中根并保存后使用默认根的工作区子目录,全局偏好仍可见;恢复自定义根后原有事实恢复。切换根目录及 Sidebar/Builtin 前后,5 个控制文件的 SHA-256 一致。 +5. 建立真实 A/B 会话,Builtin 跟随所属会话且不显示工作区选择器。重启 Host 后,两份项目事实、全局偏好、A 的档案和 Provider 映射均保留。 +6. 当前会话属于 B 时查看 A,差异提示出现;对齐操作返回 B。完成中文深色、英文浅色及 390×844 Sidebar 验证。 + +磁盘断言确认 A 的 `runtime`、`data`、`documents`、`state` 同属一个哈希子目录,两个项目均没有 `.mnemon`。桌面 Provider 画布的可视/滚动宽度均为 992px;窄屏 Runtime 和 Memory Spaces 均为 326px。最终浏览器控制台未发现错误。 + +截图复查发现并修正了长目录导致的横向溢出、英文差异提示挤压工作区选择器两处布局问题。最终截图中的路径受列宽约束,标题栏控件可换行;视觉验收后同步更新了已有样式指纹。 + +## 截图 + +| 证据 | 内容 | +|---|---| +| [01 中文深色设置](./01-settings-zh-dark.png) | 范围、可选集中根和独立全局用户档案 | +| [02 A 运行时](./02-workspace-a-runtime.png) | A 项目事实与全局偏好 | +| [03 A 档案](./03-workspace-a-document.png) | 托管档案创建成功 | +| [04 A Provider](./04-workspace-a-providers.png) | 真实 Native 数据库、Holographic 映射及修正后的长路径布局 | +| [05 B 隔离](./05-workspace-b-isolation.png) | 全局偏好可见,A 项目事实不可见 | +| [06 英文浅色设置](./06-settings-en-light.png) | 完整的集中存储标签与目录控件 | +| [07 重启后的 Builtin](./07-builtin-b-after-restart.png) | B 所属会话读取恢复后的 B 事实 | +| [08 工作区对齐](./08-workspace-alignment.png) | 查看 A、执行 B,选择器和对齐操作均可见 | +| [09 390px Sidebar](./09-sidebar-390px.png) | 窄屏记忆空间、长路径和对齐控件 | + +## 验证限制 + +本次验证本地持久化和 UI/传输,不代表真实模型沉淀质量或远端 Provider 账号验收。5 个真实 Flash 检查未开启,Windows 二进制冒烟需要 Windows 环境。移动或重命名工作区会使用新的路径哈希,旧数据保留且不自动迁移;远端 Provider 仍遵循自身命名空间共享语义。验证结束后清理临时数据与浏览器标签页,截图仅含合成内容和临时路径。 diff --git a/docs/zh-CN/development/README.md b/docs/zh-CN/development/README.md index 9d509696f..fc59f950d 100644 --- a/docs/zh-CN/development/README.md +++ b/docs/zh-CN/development/README.md @@ -111,7 +111,7 @@ pnpm e2e:serve 验证内嵌 Electron Host 时,使用 `pnpm e2e:serve --electron=/absolute/path/to/electron`(macOS 指向 `Electron.app/Contents/MacOS/Electron`)。单独安装测试用 Electron,并通过 `MNEMON_CLI_PATH` 和 `npm_config_prefix` 指定隔离的 npm 安装。夹具将正式发布的 DSH Web 栈运行在 Electron 主进程内,Host 不设置 `ELECTRON_RUN_AS_NODE`。它为正式 Cordis loader 开放 Node internals,无需重新编译或修改 DSH 包。照常用 Ctrl-C 停止。 -另检查 `displayMode` 实时切换:Sidebar 与 Builtin 不得同时挂载,二者使用同一组 Source 页面。Builtin 的全局/工作区/自定义范围读写及任务遵循所属会话,隐藏范围控件,切换会话时清理旧数据与编辑器。验证旧 `buildin` 规范化,以及原生 Sidebar 皮肤和已支持布局插件下的折叠图标。 +另检查 `displayMode` 实时切换:Sidebar 与 Builtin 不得同时挂载,二者使用同一组 Source 页面。Builtin 的全局/工作区/集中工作区/自定义范围读写及任务遵循所属会话,隐藏范围控件,切换会话时清理旧数据与编辑器。验证旧 `buildin` 规范化,以及原生 Sidebar 皮肤和已支持布局插件下的折叠图标。 [2026-09-04 main rebase 验证记录](../../pr-assets/main-rebase-20260904/README.md)列明精确的 v0.4.7/DSH rc.1 revision、registry 与源码覆盖完整测试、独立制品、插件组合重启持久化和真实双入口验证及其限制。 diff --git a/docs/zh-CN/development/architecture.md b/docs/zh-CN/development/architecture.md index 67393094f..cf0c14a85 100644 --- a/docs/zh-CN/development/architecture.md +++ b/docs/zh-CN/development/architecture.md @@ -37,6 +37,12 @@ Memory Spaces **自己定义内部 Fiber 与 Provider 协议**。每个 Provider `ctx.mnemonMemory` 是真正受限的服务对象,不是给引擎套一个更窄的 TypeScript 类型。它只暴露一个注册原语,由 `installMemory` 使用;Host 执行能力留在内部。Provider 在自己的 Source 内遵循同样原则,只收到绑定子节点的 `host.install` 能力。公开测试夹具验证这些协议,不交出私有宿主对象。 +## 内置存储范围 + +存储布局属于根包的 Host 基础设施。同一解析器处理 `global`、`workspace`、`custom` 与 `workspaces`;Core 传递选定的操作范围并执行 View 权限约束。文件系统路径归一化、环境变量与用户目录默认值、工作区目录哈希留在 Host,不新增存储贡献 API 或独立发布包。 + +Host 将解析后的目录交给各默认 Source。Source 继续拥有自己的格式、事务和 Provider 状态;切换布局不搬迁其数据。显式选择全局 USER.md 时仅用户档案使用全局根,项目数据仍留在所选工作区根。设置和只读清单使用同一布局解析器。 + ## 默认插件组合 | 插件 | 记忆权威 | 默认 View 贡献 | diff --git a/docs/zh-CN/guides/capabilities.md b/docs/zh-CN/guides/capabilities.md index a937b8227..0bc41bb0b 100644 --- a/docs/zh-CN/guides/capabilities.md +++ b/docs/zh-CN/guides/capabilities.md @@ -69,11 +69,13 @@ Runtime、Documents、Memory Spaces 是独立 Source 插件;Strategy 将各实 内部仍可能使用受限 worker 完成结构化判断,但它属于实现细节。用户界面与产品文档统一称为“独立任务 Agent”。 -## 全局、工作区与自定义 +## 存储范围 - **全局**:使用 `~/.mnemon`,适合本机多个工作区和 Agent 共享控制面。 - **工作区**:使用 `/.mnemon`;Mnemon、Holographic 与 ByteRover 等本地数据面可以自动跟随工作区。 - **自定义**:本质上是显式路径的全局范围,适合团队约定或隔离演示环境。 +- **集中工作区**(`workspaces`)由 Host 内置支持:统一固定根、相互独立的工作区子目录,以及可选的全局 USER.md。切换范围保留所有旧根。 + 远程 Provider 的 workspace、user、bank、project、container 或 URI 是它们自己的命名空间,不会因 DSH 左上角切换工作区而被隐式重写。工作区页可以查看选定目录;独立任务 Agent 的写入位置始终由当前任务的有效工作区和保存的范围规则确定。 diff --git a/docs/zh-CN/guides/getting-started.md b/docs/zh-CN/guides/getting-started.md index e015bfb64..8f9d82b62 100644 --- a/docs/zh-CN/guides/getting-started.md +++ b/docs/zh-CN/guides/getting-started.md @@ -163,6 +163,10 @@ dsh --profile headless "回答前先检查持久化的项目上下文。" | **全局**(默认) | `MNEMON_DATA_DIR` 或 `~/.mnemon` | 多个工作区共享同一套记忆 | | **工作区** | `/.mnemon` | 项目隔离,并允许在工作台切换查看其他工作区 | | **自定义** | `dataDir` | 专用磁盘、挂载卷或明确的数据目录 | +| **集中工作区** | `<集中根>/workspaces/<工作区路径哈希>/` | 在统一目录集中备份,同时按项目隔离 | + +如需集中管理且按项目隔离,选择 `storageScope: workspaces` 并按需设置 `dataDir`;数据保存为 `<集中根>/workspaces/<工作区路径哈希>/`。目录设置位于范围选择器旁;切换模式时保留旧根。 + 点击保存后会先初始化新运行图,再原子切换 Host;页面自动清理旧状态并重新读取,无需刷新浏览器。切换范围不会自动迁移、合并或删除旧数据。 diff --git a/docs/zh-CN/guides/operations.md b/docs/zh-CN/guides/operations.md index 4225ad137..deea1ac3b 100644 --- a/docs/zh-CN/guides/operations.md +++ b/docs/zh-CN/guides/operations.md @@ -98,7 +98,7 @@ DSH rc.8 首次说明的可选 SQLite 不兼容性在 DSH 0.1.1-rc.2 中仍然 ## 切换存储范围 -保存 `global` / `workspace` / `custom` 后,Host 先初始化新运行图,再原子切换;页面自动重新读取,但**不会迁移数据**: +保存 `global` / `workspace` / `custom` / `workspaces` 后,Host 先初始化新运行图,再原子切换;页面自动重新读取,但**不会迁移数据**: ```text 旧范围 -- 保存设置 --> 新的空目录或既有目录 @@ -110,6 +110,9 @@ DSH rc.8 首次说明的可选 SQLite 不兼容性在 DSH 0.1.1-rc.2 中仍然 推荐迁移流程:在旧范围导出 ZIP → 切换到新范围并确认显示目录 → 导入 ZIP → 验证。工作区模式下先确认查看工作区与会话执行工作区是否是预期目标。 +使用 `workspaces` 时,备份整个集中目录可覆盖所有工作区,导出 Pack 则只覆盖所选工作区。移动或重命名工作区会产生新路径哈希;恢复旧数据需要操作者显式执行。 + + 现有回合和已委托的子 Agent activation 可能仍使用旧运行图。迁移或停用其数据前,应等待它们结束或取消这些任务。父回合结束本身不会释放异步子任务的委托;新创建或冷恢复的 activation 会捕获自己获准使用的 generation。 diff --git a/docs/zh-CN/guides/ui-guide.md b/docs/zh-CN/guides/ui-guide.md index e17001a07..61e5ba881 100644 --- a/docs/zh-CN/guides/ui-guide.md +++ b/docs/zh-CN/guides/ui-guide.md @@ -16,7 +16,7 @@ 侧栏“记忆系统”始终打开工作台,从任务看板或 SSH 返回时也一样。重复点击保留当前页面;关闭工作台请使用“返回会话”。 -使用 `displayMode: builtin` 时,从会话 Tab 打开“记忆系统”,Sidebar 不再显示入口。Host 使用所属会话的全局、工作区或自定义范围,因此顶部不显示存储模式和工作区选择器。以下 Source 页面与弹窗全部共用,对话快捷入口会打开匹配的 Tab。详见[范围映射](../reference/configuration.md#入口位置displaymode-与-tabenabled)。 +使用 `displayMode: builtin` 时,从会话 Tab 打开“记忆系统”,Sidebar 不再显示入口。Host 使用所属会话的全局、工作区、集中工作区或自定义范围,因此顶部不显示存储模式和工作区选择器。以下 Source 页面与弹窗全部共用,对话快捷入口会打开匹配的 Tab。详见[范围映射](../reference/configuration.md#入口位置displaymode-与-tabenabled)。 一级页仍按**状态、运行时、档案、记忆空间**排列。记忆空间内部再分为**概览、检索、内容、实体**,右上角保留“沉淀记忆”和“沉淀策略”。生成的 View 是每轮内部运行时产物,不单独占用导航页面,状态页也不承担插件发现或安装。 @@ -180,6 +180,8 @@ - API Key 使用经典 password 输入,眼睛按钮在显示/隐藏之间切换;不再使用“清除凭证”checkbox、“移除”独占行或“已安全保存”提示; - 三个记忆增强开关即时应用;其余改动由底部保存动作统一写入,不在设置页等待完整发现或检索;健康监控放在状态页,记忆空间发现放在概览; - 全局 / 工作区 / 自定义标签显示当前有效范围;支持相同范围语义的 Provider 复用 Mnemon 的配置框架。 +- 在“设置 → 记忆系统 → 记忆范围”选择“集中存储 · 按工作区隔离”,即可在统一目录管理相互隔离的项目记忆。同一节的“集中根目录”可留空,使用 `MNEMON_DATA_DIR` 或 `~/.mnemon`;仍可独立选择“全局用户档案”。 + - 用户档案范围可独立选择:“全局用户档案”会组合全局 USER.md 与工作区/自定义 MEMORY.md,不移动任何事实源。 每个默认记忆层只有一个总开关。“已开启”表示允许系统按需使用,并不表示每回合强制 Recall。 diff --git a/docs/zh-CN/reference/configuration.md b/docs/zh-CN/reference/configuration.md index f11e0be0f..938520e97 100644 --- a/docs/zh-CN/reference/configuration.md +++ b/docs/zh-CN/reference/configuration.md @@ -14,13 +14,13 @@ $DSH_HOME/settings.yaml 执行中的回合保留已固定的运行图。已经派发的子 Agent 保留委托运行图直到本次 activation 销毁,即使父回合已结束;后续父回合和新委托的 activation 使用新 generation。保存设置不会静默扩大既有任务的 Recall 权限。 -Web 设置页编辑 `storageScope`、独立的 `runtimeUserScope`、`dataDir`、Mnemon Native 的 Ollama 嵌入覆盖、三个记忆层的总开关、后台任务 Agent 的模型路由,以及 `mnemon-ui` 下的回合记忆条和存入记忆按钮。“全局 / 工作区”是整个记忆系统的范围;USER.md 用户档案也可以显式保持全局,而项目记忆继续跟随该范围。`custom` 数据位置、嵌入运行配置与 ZIP 备份 / 迁移收纳在 Mnemon Native 折叠栏。每个第三方 Provider 有独立的服务配置折叠栏;这里保存的是 endpoint、凭据或可执行文件等可复用服务信息,不会创建记忆空间。具体记忆空间及其数据范围仍在“记忆空间 → 概览”中创建。其他高级项需要直接修改 YAML。 +Web 设置页编辑 `storageScope`、独立的 `runtimeUserScope`、`dataDir`、Mnemon Native 的 Ollama 嵌入覆盖、三个记忆层的总开关、后台任务 Agent 的模型路由,以及 `mnemon-ui` 下的回合记忆条和存入记忆按钮。“全局 / 工作区 / 集中工作区”是整个记忆系统的范围,可选的集中根目录在范围选择器旁配置;USER.md 用户档案也可以显式保持全局,而项目记忆继续跟随该范围。`custom` 数据位置、嵌入运行配置与 ZIP 备份 / 迁移收纳在 Mnemon Native 折叠栏。每个第三方 Provider 有独立的服务配置折叠栏;这里保存的是 endpoint、凭据或可执行文件等可复用服务信息,不会创建记忆空间。具体记忆空间及其数据范围仍在“记忆空间 → 概览”中创建。其他高级项需要直接修改 YAML。 ## 完整示例 ```yaml mnemon: - storageScope: global # global | workspace | custom + storageScope: global # global | workspace | custom | workspaces runtimeUserScope: storage # storage | global # dataDir: ~/mnemon-data # custom 时必填 # cliPath: /opt/homebrew/bin/mnemon @@ -65,9 +65,9 @@ mnemon: | 配置 | 默认值 | 范围 | 实现语义 | |---|---:|---|---| -| `storageScope` | `global` | `global` / `workspace` / `custom` | 统一控制 Runtime、Documents、Memory Spaces 和预留 state 根目录 | +| `storageScope` | `global` | `global` / `workspace` / `custom` / `workspaces` | 统一控制 Runtime、Documents、Memory Spaces 和预留 state 根目录 | | `runtimeUserScope` | `storage` | `storage` / `global` | 让 USER.md 跟随当前存储根,或叠加全局 USER.md,同时保持项目 MEMORY.md 与其他层使用所选范围 | -| `dataDir` | 未设置 | 绝对路径、`~` 或 `~/...` | `custom` 时必填;旧配置只设置它时自动解析为 `custom` | +| `dataDir` | 未设置 | 绝对路径、`~` 或 `~/...` | `custom` 时必填;`workspaces` 时为可选集中根目录;旧配置只设置它时自动解析为 `custom` | | `cliPath` | 自动发现 | 可执行路径 | 显式指定 Mnemon CLI | | `store` | 未设置 | `[A-Za-z0-9][A-Za-z0-9_-]*` | 用于旧 Store 的兼容发现/首选提示;语义操作由 Memory Space 路由 | | `timeoutMs` | `10000` | 100–120000 ms | 单次 CLI 硬超时 | @@ -210,6 +210,21 @@ mnemon: 切换设置不会复制、合并或删除条目;改回 `runtimeUserScope: storage` 后,原工作区 USER.md 会重新可见。Mnemon Pack 仍表示一个所选存储根,因此工作区 Pack 不会暗中带入独立的全局 USER.md;重要的全局档案需要单独备份全局根。 +### `workspaces` + +这是 Starter 的内置模式,无需额外安装插件。在记忆范围中选择“集中存储 · 按工作区隔离”,并在同一节配置可选的“集中根目录”。 + +```yaml +mnemon: + storageScope: workspaces + dataDir: ~/central-memory # 可选;默认 MNEMON_DATA_DIR 或 ~/.mnemon + runtimeUserScope: global # 可选;只共享 USER.md +``` + +四个 area(`runtime`、`data`、`documents`、`state`)都保存在 `<集中根>/workspaces/<规范工作区路径的 SHA-256>/`。现存符号链接别名解析到同一 ID,不同工作区路径相互隔离。移动或重命名会选择新 ID,不会自动迁移。Sidebar 跟随所选的已登记工作区,Builtin 和 Headless 跟随所属会话 cwd。即使集中根为自定义目录,全局 USER.md 仍使用 `MNEMON_DATA_DIR` 或 `~/.mnemon`。 + +切换范围不会迁移、合并或删除旧根。ZIP Pack 仍只包含当前工作区根;要保留全部工作区,请备份整个集中目录。远端 Provider 命名空间仍遵循自身共享语义。 + ### `custom` ```yaml @@ -347,6 +362,7 @@ Builtin 隐藏页眉中的存储模式标记、工作区选择和对齐控件。 | `global` | 共享 `MNEMON_DATA_DIR` 或 `~/.mnemon`,不受会话工作区影响 | | `workspace` | 当前会话的 `/.mnemon`;切换会话时自动跟随各自工作区 | | `custom` | 配置的 `dataDir`,不受会话工作区影响 | +| `workspaces` | 所属会话在 `<集中根>/workspaces/<工作区路径哈希>/` 下的子目录 | 既有 `runtimeUserScope: global` 例外仍让 USER.md 保持全局。切换入口不会改变范围、迁移记忆数据或恢复旧 builtin 导航。设置 RPC 保存后实时切换入口。 diff --git a/docs/zh-CN/reference/storage-model.md b/docs/zh-CN/reference/storage-model.md index c18412e91..954f05cb9 100644 --- a/docs/zh-CN/reference/storage-model.md +++ b/docs/zh-CN/reference/storage-model.md @@ -59,6 +59,9 @@ follow an exact cold reference when full text is required `storageScope` 决定整个根,而不只是 Mnemon 数据库。`workspace` 范围会为每个已登记 DSH 工作区解析独立的 `/.mnemon`。显式启用的 `runtimeUserScope=global` 是唯一的分根例外:Runtime 从全局根读取 USER.md,MEMORY.md 与其他所有组件仍留在所选根。工作台任务使用查看工作区;对话工具与生命周期使用所属会话的 cwd 和已固定的 View。`state/memory-providers.json` 保存第三方 endpoint、目标 URI、身份和可选凭据;文件权限为 `0600`,Host 只返回已配置字段名,不回传凭据值。 +`workspaces` 布局把四个 area 集中在 `<集中根>/workspaces/<工作区路径哈希>/`;Host 解析目录时不创建文件或修改旧根。只有显式 `runtimeUserScope: global` 会把 USER.md 放在该工作区子目录之外。 + + ## 运行时记忆 @@ -137,7 +140,7 @@ Documents 保存比单条记忆更完整、又希望快速阅读的项目知识 Documents 的物理共享范围由 `storageScope` 决定: -- `workspace`:通常随项目隔离; +- `workspace` / `workspaces`:通常随项目隔离; - `global` / `custom`:多个工作区可能共享同一个 `documents/index.json`。 因此“项目档案”表示内容类型,不保证天然按工作区物理隔离。当前会话工作区只约束新写入的 `sourcePaths`。 diff --git a/plugins/dsh-mnemon-source-memory-spaces/presentation/page.module.css b/plugins/dsh-mnemon-source-memory-spaces/presentation/page.module.css index a79a1360c..66f91f26e 100644 --- a/plugins/dsh-mnemon-source-memory-spaces/presentation/page.module.css +++ b/plugins/dsh-mnemon-source-memory-spaces/presentation/page.module.css @@ -41,10 +41,11 @@ button.readSourceCard:hover { border-color: color-mix(in srgb, var(--mn-provider .readSourceState small { color: var(--mn-faint); font-size: 8px; white-space: nowrap; } .bodyDirectory { position: relative; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--mn-line); border-radius: 11px; background: color-mix(in srgb, var(--mn-layer-1) 72%, var(--mn-bg)); } -.bodyDirectoryHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 10px; } +.bodyDirectoryHeader { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 10px; } +.bodyDirectoryHeader > div:first-child { min-width: 0; flex: 1 1 260px; } .bodyDirectoryHeader h3 { margin: 1px 0; font-size: 13px; } .bodyDirectoryHeader p { margin: 0; color: var(--mn-muted); font-size: 10px; } -.bodyDirectoryPath { display: block; max-width: min(62vw, 720px); margin-top: 4px; overflow: hidden; color: var(--mn-faint); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.bodyDirectoryPath { display: block; max-width: 100%; margin-top: 4px; overflow: hidden; color: var(--mn-faint); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } .bodyDirectoryControls { display: flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: 6px; padding-right: 28px; } .bodyDirectoryControls > strong { flex: 0 0 auto; padding: 5px 8px; border-radius: 999px; color: var(--mn-accent); background: color-mix(in srgb, var(--mn-accent) 9%, transparent); font: 650 9px var(--mn-code); } .bodyGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(245px, 1fr)); gap: 7px; } diff --git a/scripts/verify-package-contents.mjs b/scripts/verify-package-contents.mjs index d1499dca0..be09d9053 100644 --- a/scripts/verify-package-contents.mjs +++ b/scripts/verify-package-contents.mjs @@ -41,7 +41,9 @@ const relativeReadmeImages = readmeFiles.flatMap((path) => { // Core/Host and the shared page kit only; Source/Provider implementations must // ship in their own artifacts. Keep a bounded budget, not the old monolith size. -const maximumUnpackedBytes = 1_250_000 +// Built-in workspace routing and bilingual settings add about 7 KB to main's +// 1,249,360-byte baseline, without adding package files or bundled Sources. +const maximumUnpackedBytes = 1_260_000 if (missing.length > 0 || unexpected.length > 0 || hostLeaks.length > 0 || relativeReadmeImages.length > 0 || pack.unpackedSize > maximumUnpackedBytes) { if (missing.length > 0) console.error(`Missing package files:\n${missing.map(path => `- ${path}`).join('\n')}`) diff --git a/src/client/MnemonPackSection.tsx b/src/client/MnemonPackSection.tsx index 84e43b164..02405dfdc 100644 --- a/src/client/MnemonPackSection.tsx +++ b/src/client/MnemonPackSection.tsx @@ -77,7 +77,7 @@ export function MnemonPackSection({ connection, sessionId, workspaceId, refreshK return () => { active = false } }, [client, refreshKey]) - const scopeLabel = (scope: string): string => scope === 'global' ? t('config.global') : scope === 'workspace' ? t('config.workspace') : t('config.custom') + const scopeLabel = (scope: string): string => scope === 'global' ? t('config.global') : scope === 'workspace' ? t('config.workspace') : scope === 'workspaces' ? t('config.workspaces') : t('config.custom') const exportZip = async (): Promise => { if (client === null || busy !== null) return diff --git a/src/client/MnemonSettingsCard.module.css b/src/client/MnemonSettingsCard.module.css index b8c1c2899..139fb31aa 100644 --- a/src/client/MnemonSettingsCard.module.css +++ b/src/client/MnemonSettingsCard.module.css @@ -624,3 +624,7 @@ .choiceFace, .switch, .switch i, .providerChevron, .providerToggle > span, .providerToggle > span > i { transition: none; } .miniSpinner { animation: none; } } + +.storageChoiceGrid > :last-child { grid-column: 1 / -1; } +.workspaceStorageLocation { margin-top: 16px; } +.workspaceStorageLocation > p { margin: 10px 0 0; color: var(--dsw-alias-label-caption); font-size: 12px; line-height: 1.5; } diff --git a/src/client/MnemonSettingsCard.tsx b/src/client/MnemonSettingsCard.tsx index 42c745d1e..3528338ec 100644 --- a/src/client/MnemonSettingsCard.tsx +++ b/src/client/MnemonSettingsCard.tsx @@ -5,6 +5,7 @@ import { DEFAULT_EMBEDDING_PROTOCOL, MNEMON_EMBEDDING_PROTOCOLS, normalizeDisplayMode, + isWorkspaceStorageScope, type ClientConnectionHandle, type ClientSettingsScope, type ClientSettingsSnapshot, @@ -146,16 +147,16 @@ function topologyOf(descriptor: MemoryCompositionStatus): MemoryTopologyDefiniti } function validation(t: MnemonTranslate, draft: Draft): string | null { - if (!['global', 'workspace', 'custom'].includes(draft.storageScope)) return t('config.invalidScope') + if (!['global', 'workspace', 'custom', 'workspaces'].includes(draft.storageScope)) return t('config.invalidScope') if (!['storage', 'global'].includes(draft.runtimeUserScope)) return t('config.invalidRuntimeUserScope') - if (draft.storageScope === 'custom') { + if (draft.storageScope === 'custom' || (draft.storageScope === 'workspaces' && draft.dataDir.trim() !== '')) { const directory = draft.dataDir.trim() if (directory === '') return t('config.customRequired') const posixAbsolute = directory.startsWith('/') const homeRelative = directory === '~' || directory.startsWith('~/') const windowsDriveAbsolute = /^[a-zA-Z]:[\\/]/.test(directory) const windowsUncAbsolute = /^\\\\[^\\/]+[\\/][^\\/]+/.test(directory) - if (!posixAbsolute && !homeRelative && !windowsDriveAbsolute && !windowsUncAbsolute) return t('config.customAbsolute') + if (directory.includes('\0') || (!posixAbsolute && !homeRelative && !windowsDriveAbsolute && !windowsUncAbsolute)) return t('config.customAbsolute') } if (draft.embeddingEnabled && !validEmbeddingEndpoint(draft.embeddingEndpoint)) return t('config.embeddingEndpointInvalid') if (draft.embeddingEnabled && !validEmbeddingModel(draft.embeddingModel)) return t('config.embeddingModelInvalid') @@ -174,7 +175,7 @@ function useScope(scope: ClientSettingsScope): ClientSettingsSnapshot { function operations(fields: readonly DraftField[], dirty: ReadonlySet, draft: Draft): SettingsOperation[] { return fields.flatMap((field): SettingsOperation[] => { if (!dirty.has(field)) return [] - if (field === 'dataDir' && draft.dataDir.trim() === '') return [{ op: 'unset', path: [field] }] + if (field === 'dataDir' && draft.dataDir.trim() === '' && draft.storageScope !== 'workspaces') return [{ op: 'unset', path: [field] }] const value = draft[field] return [{ op: 'set', path: [field], value: typeof value === 'string' ? value.trim() : value }] }) @@ -320,7 +321,7 @@ export function MnemonSettingsCard({ scope, interactionScope: suppliedInteractio } const coreUser = useMemo(() => record(coreSnapshot.user), [coreSnapshot.user]) - const activeScope = coreDraft(coreSnapshot.value).storageScope === 'workspace' ? 'workspace' : 'global' + const activeScope = isWorkspaceStorageScope(coreDraft(coreSnapshot.value).storageScope) ? 'workspace' : 'global' const error = validation(t, draft) const loading = coreSnapshot.status === 'loading' || interactionSnapshot.status === 'loading' // A successful writable settings snapshot is the Host's authoritative @@ -455,10 +456,23 @@ export function MnemonSettingsCard({ scope, interactionScope: suppliedInteractio

{t('config.storageTitle')}

{t('config.storageDescription')}

-
- edit('storageScope', draft.dataDir.trim() === '' ? 'global' : 'custom')} /> +
+ edit('storageScope', draft.dataDir.trim() === '' ? 'global' : 'custom')} /> edit('storageScope', 'workspace')} /> + edit('storageScope', 'workspaces')} />
+ {draft.storageScope === 'workspaces' &&
+
+ +
+ edit('dataDir', event.target.value)} /> +
+
+

{t('config.workspacesIdentityHint')}

+
}
@@ -498,7 +512,7 @@ export function MnemonSettingsCard({ scope, interactionScope: suppliedInteractio {t(`config.${activeScope}`)}{t('config.officialNative')}
-
- + } 0 - const workspaceDiverged = workspaceContext?.mode === 'workspace' && !workspaceContext.aligned + const showWorkspacePicker = isWorkspaceStorageScope(storageMode) && workspaceSelection !== undefined && workspaceSelection.options.length > 0 + const workspaceDiverged = workspaceContext !== undefined && isWorkspaceStorageScope(workspaceContext.mode) && !workspaceContext.aligned const canAlignWorkspace = workspaceDiverged && workspaceSelection?.effectiveWorkspaceId !== undefined const workspaceDifference = workspaceContext === undefined ? '' diff --git a/src/client/locales.ts b/src/client/locales.ts index c0446244e..7b275efc5 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -251,6 +251,7 @@ export const zh = { 'status.storageScopeAria': '选择要查看的存储域', 'status.storageGlobal': '全局', 'status.storageWorkspace': '工作区', + 'status.storageWorkspaces': '集中存储 · 工作区隔离', 'status.storageCustom': '自定义', 'status.storageCurrent': '当前读写', 'status.storageWaiting': '正在读取存储域目录…', @@ -299,6 +300,12 @@ export const zh = { 'config.scopeAria': '记忆系统范围', 'config.global': '全局', 'config.workspace': '工作区', + 'config.workspaces': '集中存储 · 按工作区隔离', + 'config.workspacesHint': '统一根目录下,每个工作区使用独立子目录', + 'config.workspacesRoot': '集中根目录', + 'config.workspacesRootHint': '可选。使用绝对路径或 ~/;留空使用 MNEMON_DATA_DIR 或 ~/.mnemon。', + 'config.workspacesDefault': '默认目录(MNEMON_DATA_DIR 或 ~/.mnemon)', + 'config.workspacesIdentityHint': '数据保存在 workspaces/<工作区路径哈希>/ 下。移动或重命名工作区会使用新目录;切换范围不会迁移、合并或删除旧数据。', 'config.custom': '自定义', 'config.customHintShort': '填写一个目录', 'config.customSelected': '已填写目录', @@ -767,6 +774,7 @@ export const en = { 'status.storageScopeAria': 'Select a storage domain to inspect', 'status.storageGlobal': 'Global', 'status.storageWorkspace': 'Workspace', + 'status.storageWorkspaces': 'Centralized workspaces', 'status.storageCustom': 'Custom', 'status.storageCurrent': 'Current read/write', 'status.storageWaiting': 'Reading storage-domain directories…', @@ -815,6 +823,12 @@ export const en = { 'config.scopeAria': 'Memory system scope', 'config.global': 'Global', 'config.workspace': 'Workspace', + 'config.workspaces': 'Centralized · isolated by workspace', + 'config.workspacesHint': 'One root directory with a separate subdirectory for each workspace', + 'config.workspacesRoot': 'Central root directory', + 'config.workspacesRootHint': 'Optional. Use an absolute path or ~/; leave empty for MNEMON_DATA_DIR or ~/.mnemon.', + 'config.workspacesDefault': 'Default (MNEMON_DATA_DIR or ~/.mnemon)', + 'config.workspacesIdentityHint': 'Data lives in workspaces//. Moving or renaming a workspace selects a new directory. Switching scopes never migrates, merges, or deletes old data.', 'config.custom': 'Custom', 'config.customHintShort': 'Enter one directory', 'config.customSelected': 'Directory entered', diff --git a/src/core/contracts/index.ts b/src/core/contracts/index.ts index c5d5f3e28..a3e54d4c3 100644 --- a/src/core/contracts/index.ts +++ b/src/core/contracts/index.ts @@ -22,7 +22,7 @@ export const MEMORY_CAPABILITIES = [ export type MemoryCapability = typeof MEMORY_CAPABILITIES[number] export interface MemoryOperationScope { - storage: 'global' | 'workspace' | 'custom' + storage: 'global' | 'workspace' | 'custom' | 'workspaces' workspaceId?: string sessionId?: string agentId?: string diff --git a/src/host/config.ts b/src/host/config.ts index 549199e51..ee5637740 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -137,7 +137,7 @@ export const Config: z = z.object({ // Source/Strategy Entries without double registration. // Keep this optional in the schema so legacy dataDir-only installs still // resolve to the custom scope instead of being silently reset to global. - storageScope: z.union(['global', 'workspace', 'custom'] as const), + storageScope: z.union(['global', 'workspace', 'custom', 'workspaces'] as const), runtimeUserScope: z.union(['storage', 'global'] as const).default('storage'), cliPath: z.string(), dataDir: z.string(), @@ -208,6 +208,7 @@ const CUSTOM_PACK_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ function validateCustomDataDir(value: string): string { const dataDir = optionalText(value) if (dataDir === undefined) throw new Error('dsh-mnemon: custom Pack dataDir is required') + if (dataDir.includes('\0')) throw new Error('dsh-mnemon: dataDir must not contain a null byte') if (!isAbsolute(dataDir) && dataDir !== '~' && !dataDir.startsWith('~/')) { throw new Error('dsh-mnemon: custom Pack dataDir must be absolute or start with ~/') } @@ -310,6 +311,7 @@ export function resolveConfig(config: Config = {}): ResolvedConfig { if (requestedPackId !== undefined && !CUSTOM_PACK_ID.test(requestedPackId)) throw new Error('dsh-mnemon: customPackId is invalid') const store = optionalText(config.store) const storageScope = config.storageScope ?? (legacyDataDir === undefined && legacyPacks.length === 0 ? 'global' : 'custom') + if (!['global', 'workspace', 'custom', 'workspaces'].includes(storageScope)) throw new Error('dsh-mnemon: unsupported storageScope') const runtimeUserScope = config.runtimeUserScope ?? 'storage' if (runtimeUserScope !== 'storage' && runtimeUserScope !== 'global') throw new Error(`dsh-mnemon: unsupported Runtime USER.md scope: ${String(runtimeUserScope)}`) const selectedPack = requestedPackId === undefined diff --git a/src/host/protocol.ts b/src/host/protocol.ts index ce0b66773..8bb85bf58 100644 --- a/src/host/protocol.ts +++ b/src/host/protocol.ts @@ -118,7 +118,11 @@ export interface ClientSettingsScope { export type SettingsOperation = { op: 'set'; path: string[]; value: unknown } | { op: 'unset'; path: string[] } -export type StorageScopeKind = 'global' | 'workspace' | 'custom' +export type StorageScopeKind = 'global' | 'workspace' | 'custom' | 'workspaces' + +export function isWorkspaceStorageScope(scope: string | undefined): boolean { + return scope === 'workspace' || scope === 'workspaces' +} export interface MemoryLayerConfig { enabled?: boolean diff --git a/src/host/rpc.ts b/src/host/rpc.ts index 29773754f..267f436f5 100644 --- a/src/host/rpc.ts +++ b/src/host/rpc.ts @@ -1,4 +1,4 @@ -import { isDefaultSourceInstance } from './protocol.ts' +import { isDefaultSourceInstance, isWorkspaceStorageScope } from './protocol.ts' import type { HostConnectionHandle, HostRpcAuthority, HostRpcHandler, RpcResult } from './dsh.ts' import type { MnemonLifecycle } from './lifecycle.ts' import type { LiveMnemonRuntime } from './runtime.ts' @@ -216,7 +216,7 @@ export function createReadHandler(input: LiveMnemonRuntime, lifecycle?: MnemonLi return success({ ...status, ...(versions === undefined ? {} : { dshMnemonVersion: versions.currentDshMnemonVersion }), - ...(lifecycle === undefined ? {} : { lifecycle: lifecycle.snapshot(runtime.scope.sessionId, runtime.graph.config.storageScope === 'workspace' ? runtime.scope.workspaceId : undefined) }), + ...(lifecycle === undefined ? {} : { lifecycle: lifecycle.snapshot(runtime.scope.sessionId, isWorkspaceStorageScope(runtime.graph.config.storageScope) ? runtime.scope.workspaceId : undefined) }), ...(documents === undefined ? {} : { documents }), memorySystem: composition, storage: runtime.graph.storage.catalog(runtime.scope.workspaceId), diff --git a/src/host/runtime.ts b/src/host/runtime.ts index e92c927a2..01a481914 100644 --- a/src/host/runtime.ts +++ b/src/host/runtime.ts @@ -1,10 +1,11 @@ -import { isDefaultSourceInstance } from './protocol.ts' +import { isDefaultSourceInstance, isWorkspaceStorageScope } from './protocol.ts' import { resolve } from 'node:path' import type { ResolvedConfig } from './config.ts' import type { HostAgent, HostAgentsService, HostWorkspace, HostWorkspaceRegistry } from './dsh.ts' import { MnemonPackManager } from './pack.ts' import { StorageScopeInspector } from './storage-scope.ts' import { createStorageRoot } from './storage-root.ts' +import { canonicalWorkspacePath } from './workspace-storage.ts' import { SourceSession } from './source-session.ts' import { MemoryRuntime } from '../core/runtime.ts' import type { MemoryGenerationHost } from '../core/generation.ts' @@ -190,7 +191,7 @@ export class LiveMnemonRuntime implements MnemonAgentRuntimeSource { const parentSession = agent.session.header?.origin === 'subagent' ? agent.session.header.parentSession?.trim() : undefined const inherited = parentSession === undefined || parentSession === '' ? undefined : this.agentGraphs.get(parentSession) if (inherited !== undefined) return inherited.graph - if (this.current.config.storageScope !== 'workspace') return this.current + if (!isWorkspaceStorageScope(this.current.config.storageScope)) return this.current const cwd = agent.session.header?.cwd?.trim() if (cwd === undefined || cwd === '') throw new Error('the current DSH session has no workspace for Mnemon') return this.forWorkspacePath(cwd) @@ -200,7 +201,7 @@ export class LiveMnemonRuntime implements MnemonAgentRuntimeSource { forWorkspaceId(workspaceId: string): MnemonRuntimeGraph { this.assertOpen() const workspace = this.requireWorkspace(workspaceId) - return this.current.config.storageScope === 'workspace' ? this.forWorkspacePath(workspace.path) : this.current + return isWorkspaceStorageScope(this.current.config.storageScope) ? this.forWorkspacePath(workspace.path) : this.current } /** Resolve a Web request, preferring its explicit inspection workspace. */ @@ -220,7 +221,7 @@ export class LiveMnemonRuntime implements MnemonAgentRuntimeSource { : this.requireWorkspace(request.workspaceId) const graph = selectedWorkspace === undefined ? effectiveAgent === undefined ? this.current : this.forAgent(effectiveAgent) - : this.current.config.storageScope === 'workspace' ? this.forWorkspacePath(selectedWorkspace.path) : this.current + : isWorkspaceStorageScope(this.current.config.storageScope) ? this.forWorkspacePath(selectedWorkspace.path) : this.current const effectiveGraph = effectiveAgent === undefined ? this.current : this.forAgent(effectiveAgent) const selectedRoot = resolve(graph.directory) const effectiveRoot = resolve(effectiveGraph.directory) @@ -235,7 +236,7 @@ export class LiveMnemonRuntime implements MnemonAgentRuntimeSource { } private forWorkspacePath(workspaceRoot: string): MnemonRuntimeGraph { - const key = resolve(workspaceRoot) + const key = this.current.config.storageScope === 'workspaces' ? canonicalWorkspacePath(resolve(workspaceRoot)) : resolve(workspaceRoot) let graph = this.workspaceGraphs.get(key) if (graph === undefined) { graph = createRuntimeGraph(this.current.config, key, this.extensions) diff --git a/src/host/storage-root.ts b/src/host/storage-root.ts index 3b8f9d71d..d7ed77911 100644 --- a/src/host/storage-root.ts +++ b/src/host/storage-root.ts @@ -2,17 +2,30 @@ import { homedir } from 'node:os' import { join, resolve } from 'node:path' import type { ResolvedConfig } from './config.ts' import { withMemoryStorageLock } from '../sdk/storage-lock.ts' +import { workspaceStorageId } from './workspace-storage.ts' export interface StorageRoot { effectiveDataDir(): string withExclusive(operation: () => T | Promise): Promise } -/** The default product's location preference; individual Sources own storage. */ +function expandDirectory(value: string): string { + return resolve(value === '~' ? homedir() : value.startsWith('~/') ? join(homedir(), value.slice(2)) : value) +} + +/** Host owns every built-in layout; Sources own the data within the chosen root. */ +function storageDirectory(config: Pick, workspaceRoot?: string): string { + const globalRoot = process.env.MNEMON_DATA_DIR?.trim() || '~/.mnemon' + switch (config.storageScope) { + case 'global': return expandDirectory(globalRoot) + case 'custom': return expandDirectory(config.dataDir!) + case 'workspace': return resolve(workspaceRoot ?? process.cwd(), '.mnemon') + case 'workspaces': return join(expandDirectory(config.dataDir ?? globalRoot), 'workspaces', workspaceStorageId(resolve(workspaceRoot ?? process.cwd()))) + } +} + export function createStorageRoot(config: Pick, workspaceRoot?: string): StorageRoot { - const value = config.storageScope === 'workspace' ? resolve(workspaceRoot ?? process.cwd(), '.mnemon') - : config.storageScope === 'custom' ? config.dataDir! : process.env.MNEMON_DATA_DIR?.trim() || '~/.mnemon' - const directory = resolve(value === '~' ? homedir() : value.startsWith('~/') ? join(homedir(), value.slice(2)) : value) + const directory = storageDirectory(config, workspaceRoot) return { effectiveDataDir: () => directory, withExclusive: operation => withMemoryStorageLock(directory, operation), diff --git a/src/host/storage-scope.ts b/src/host/storage-scope.ts index e300982d0..be7131386 100644 --- a/src/host/storage-scope.ts +++ b/src/host/storage-scope.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' import type { ResolvedConfig } from './config.ts' import type { StorageRoot } from './storage-root.ts' +import { createStorageRoot } from './storage-root.ts' import type { StorageAreaInventory, StorageAreaKind, StorageAreaStatus, StorageScopeCatalog, StorageScopeInventory, StorageScopeKind } from "./protocol.ts" export type { StorageAreaInventory, StorageAreaKind, StorageAreaStatus, StorageScopeCatalog, StorageScopeInventory, StorageScopeKind } from "./protocol.ts" @@ -147,7 +148,7 @@ function inspect(kind: StorageScopeKind, rawRoot: string | undefined, activeRoot } } -/** Read-only catalog of the three storage domains. It never creates, moves, or repairs files. */ +/** Read-only storage catalog. It never creates, moves, or repairs files. */ export class StorageScopeInspector { constructor(private readonly runner: Pick, private readonly config: Pick) {} @@ -158,6 +159,7 @@ export class StorageScopeInspector { const configuredDataDir = this.config.dataDir === undefined ? undefined : canonical(this.config.dataDir) const activeKind: StorageScopeKind = this.config.storageScope const custom = configuredDataDir !== undefined && configuredDataDir !== global && configuredDataDir !== workspace ? configuredDataDir : undefined + const workspaces = workspace === undefined ? undefined : createStorageRoot({ ...this.config, storageScope: 'workspaces' }, workspaceRoot).effectiveDataDir() return { activeKind, activeRoot, @@ -165,7 +167,8 @@ export class StorageScopeInspector { inspect('global', activeKind === 'global' ? activeRoot : global, activeRoot), inspect('workspace', activeKind === 'workspace' ? activeRoot : workspace, activeRoot), inspect('custom', activeKind === 'custom' ? activeRoot : custom, activeRoot), - ], + inspect('workspaces', activeKind === 'workspaces' ? activeRoot : workspaces, activeRoot), + ].map(scope => ({ ...scope, active: scope.kind === activeKind })), generatedAt: new Date().toISOString(), } } @@ -174,6 +177,7 @@ export class StorageScopeInspector { export function validateCustomStorageRoot(value: string): string { const path = value.trim() if (path === '') throw new Error('custom storage directory is required') + if (path.includes('\0')) throw new Error('custom storage directory must not contain a null byte') const expanded = expandHome(path) if (!isAbsolute(expanded)) throw new Error('custom storage directory must be an absolute path or start with ~/') return canonical(path) diff --git a/src/host/workspace-storage.ts b/src/host/workspace-storage.ts new file mode 100644 index 000000000..999fe4875 --- /dev/null +++ b/src/host/workspace-storage.ts @@ -0,0 +1,25 @@ +import { createHash } from 'node:crypto' +import { realpathSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +/** Host workspace identity, including aliases with not-yet-created descendants. */ +export function canonicalWorkspacePath(workspacePath: string): string { + if (!isAbsolute(workspacePath) || workspacePath.includes('\0')) throw new Error('Workspace path must be absolute') + let parent = resolve(workspacePath) + const suffix: string[] = [] + for (;;) { + try { return join(realpathSync.native(parent), ...suffix) } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const next = dirname(parent) + if (next === parent) throw error + suffix.unshift(parent.slice(next.length).replace(/^[/\\]+/u, '')) + parent = next + } + } +} + +/** A rename or move selects a new directory; resolving identity never writes. */ +export function workspaceStorageId(workspacePath: string): string { + return createHash('sha256').update(canonicalWorkspacePath(workspacePath)).digest('hex') +} diff --git a/tests/client-presentation.spec.ts b/tests/client-presentation.spec.ts index aa78fce38..88ec36f8b 100644 --- a/tests/client-presentation.spec.ts +++ b/tests/client-presentation.spec.ts @@ -16,17 +16,17 @@ describe('default Source presentation migration', () => { expect(memoryPageStyles.primaryButton).toContain('primaryButton') }) - it.each(['page', 'sidebar'] as const)('preserves the %s class map with the reviewed compact-header, version-maintenance, and memory space layout changes', kind => { + it.each(['page', 'sidebar'] as const)('preserves the %s class map with the reviewed layout changes', kind => { const filename = kind === 'page' ? 'src/client/MnemonView.module.css' : 'src/client/MnemonSidebarView.module.css' const files = [filename, ...sources.map(source => `plugins/dsh-mnemon-source-${source}/presentation/${kind}.module.css`)] // Rules include their container/media conditions. Browser checks cover cascade and layout. - const expected = { ...baseline[kind], ...baseline.memorySpaceTerminology[kind] } + const expected = { ...baseline[kind], ...baseline.memorySpaceTerminology[kind], ...baseline.centralizedWorkspaces[kind] } expect(presentationFingerprint(files.map(path => ({ filename: presentationNamespace(path), text: read(path) })))).toEqual(expected) }) it('preserves bilingual memory space terminology while Sources own their copy', () => { - expect(copyFingerprint(zh)).toEqual(baseline.memorySpaceTerminology.zh) - expect(copyFingerprint(en)).toEqual(baseline.memorySpaceTerminology.en) + expect(copyFingerprint(zh)).toEqual(baseline.centralizedWorkspaces.zh) + expect(copyFingerprint(en)).toEqual(baseline.centralizedWorkspaces.en) for (const source of sources) { const copy = JSON.parse(read(`plugins/dsh-mnemon-source-${source}/presentation/locales.json`)) expect(Object.keys(copy.en).sort()).toEqual(Object.keys(copy.zh).sort()) diff --git a/tests/client-settings.spec.tsx b/tests/client-settings.spec.tsx index 688f18134..e794ded36 100644 --- a/tests/client-settings.spec.tsx +++ b/tests/client-settings.spec.tsx @@ -477,7 +477,7 @@ describe('MnemonSettingsCard', () => { } satisfies ClientSettingsScope & { snapshot: typeof snapshot } render() - fireEvent.click(screen.getByRole('radio', { name: /工作区/ })) + fireEvent.click(screen.getByRole('radio', { name: '工作区' })) fireEvent.click(screen.getByRole('button', { name: '保存' })) await waitFor(() => expect(set).toHaveBeenCalledWith('storageScope', 'workspace')) @@ -508,7 +508,7 @@ describe('MnemonSettingsCard', () => { render() - expect((screen.getByRole('radio', { name: /工作区/ }) as HTMLInputElement).checked).toBe(true) + expect((screen.getByRole('radio', { name: '工作区' }) as HTMLInputElement).checked).toBe(true) expect((screen.getByRole('radio', { name: /跟随记忆范围/ }) as HTMLInputElement).checked).toBe(true) fireEvent.click(screen.getByRole('radio', { name: /全局用户档案/ })) fireEvent.click(screen.getByRole('button', { name: '保存' })) @@ -1155,3 +1155,48 @@ describe('MnemonSettingsCard', () => { expect(screen.getByText('已将 ZIP 安全合并到 /active/.mnemon。')).toBeTruthy() }) }) + +describe('centralized workspace storage settings', () => { + function settings(value: Config, writable = true) { + const snapshot = { status: 'ready' as const, value, base: {}, user: {}, revision: 0, writable, mode: 'host' as const } + const mutate = vi.fn(async () => {}) + const scope: ClientSettingsScope = { + getSnapshot: () => snapshot, subscribe: () => () => {}, + set: vi.fn(), unset: vi.fn(), setPath: vi.fn(), unsetPath: vi.fn(), mutate, + } + return { scope, mutate } + } + it('saves the scope, central root and global profile together from the storage section', async () => { + const { scope, mutate } = settings({ storageScope: 'global' }) + render() + fireEvent.click(screen.getByRole('radio', { name: '集中存储 · 按工作区隔离' })) + const section = screen.getByRole('region', { name: '记忆范围' }) + fireEvent.change(within(section).getByRole('textbox', { name: '集中根目录' }), { target: { value: ' /tmp/central-memory ' } }) + fireEvent.click(screen.getByRole('radio', { name: '全局用户档案' })) + expect(screen.queryByRole('radiogroup', { name: '全局数据位置' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '保存' })) + await waitFor(() => expect(mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['storageScope'], value: 'workspaces' }, + { op: 'set', path: ['runtimeUserScope'], value: 'global' }, + { op: 'set', path: ['dataDir'], value: '/tmp/central-memory' }, + ])) + }) + it('rejects a relative central root and lets an empty value restore the default without changing scope', async () => { + const { scope, mutate } = settings({ storageScope: 'workspaces', dataDir: '/old-root' }) + render() + const input = screen.getByRole('textbox', { name: 'Central root directory' }) + fireEvent.change(input, { target: { value: 'relative' } }) + expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByRole('alert').textContent).toContain('absolute') + fireEvent.change(input, { target: { value: '' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(mutate).toHaveBeenCalledWith([{ op: 'set', path: ['dataDir'], value: '' }])) + }) + it('disables the new scope and its root when settings are read-only', () => { + const { scope, mutate } = settings({ storageScope: 'workspaces' }, false) + render() + expect((screen.getByRole('radio', { name: '集中存储 · 按工作区隔离' }) as HTMLInputElement).disabled).toBe(true) + expect((screen.getByRole('textbox', { name: '集中根目录' }) as HTMLInputElement).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + }) +}) diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 9e0625158..c7f5a55d8 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -200,3 +200,17 @@ describe('Mnemon config and resolution', () => { }) }) + +describe('centralized workspace configuration', () => { + it('accepts default, home-relative and custom central roots with either USER.md scope', () => { + for (const runtimeUserScope of ['storage', 'global'] as const) { + for (const dataDir of [undefined, '', '~/central-memory', '/central-memory']) { + const input = { storageScope: 'workspaces' as const, runtimeUserScope, ...(dataDir === undefined ? {} : { dataDir }) } + expect(resolveConfig(Config(input))).toMatchObject({ storageScope: 'workspaces', runtimeUserScope }) + } + } + expect(() => resolveConfig({ storageScope: 'workspaces', dataDir: 'relative' })).toThrow('absolute') + expect(() => resolveConfig({ storageScope: 'workspaces', dataDir: '/root\0' })).toThrow('null byte') + expect(() => resolveConfig({ storageScope: 'unknown' as never })).toThrow('storageScope') + }) +}) diff --git a/tests/fixtures/presentation-baseline.json b/tests/fixtures/presentation-baseline.json index f97f26612..2c9d70c5d 100644 --- a/tests/fixtures/presentation-baseline.json +++ b/tests/fixtures/presentation-baseline.json @@ -61,5 +61,23 @@ "classes": 29, "classesSha256": "c2eeeb8d1e00063d29bfe2949339820184f3e8b22f631c362edd85aad8bfe573" } + }, + "centralizedWorkspaces": { + "page": { + "rules": 1068, + "rulesSha256": "45b2036ac30794e47cb9b862e5d8f3f2c968567dc8a953167db5622d727ccd3a" + }, + "sidebar": { + "rules": 286, + "rulesSha256": "66a37faa5c5875bc114a284b2711ce521e3cf5fab6f3b5c56fe1a15adaf86bdd" + }, + "en": { + "keys": 977, + "sha256": "ee02f8169a503bd28d28ef60ea4cfeb0f9c396e01e7ee45a638ca91bad64796c" + }, + "zh": { + "keys": 977, + "sha256": "00725a3de722df41e269a43dca686c2cc0981762e36bcfdc05c73cc55936db4b" + } } } diff --git a/tests/live-runtime.spec.ts b/tests/live-runtime.spec.ts index 8bafa279e..2e205477b 100644 --- a/tests/live-runtime.spec.ts +++ b/tests/live-runtime.spec.ts @@ -1,10 +1,11 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMemorySnapshot } from 'dsh-mnemon-source-runtime/contracts' import type { HostAgent, HostWorkspaceRegistry, HostAgentsService, HostContextShape, HostSubagentsService } from '../src/host/dsh.ts' import { createRuntimeGraph, LiveMnemonRuntime } from '../src/host/runtime.ts' +import { createStorageRoot } from '../src/host/storage-root.ts' import { resolveConfig } from '../src/host/config.ts' import { compositionFixture } from './fixtures/composition.ts' import { createReadHandler, createWriteHandler } from '../src/host/rpc.ts' @@ -27,7 +28,7 @@ afterEach(async () => { }) describe('default Host scope over the Composable Runtime', () => { - it.each(['global', 'workspace', 'custom', 'legacy-custom'] as const)('keeps Builtin session-only reads and writes in %s storage through real Sources', async storageScope => { + it.each(['global', 'workspace', 'workspaces', 'custom', 'legacy-custom'] as const)('keeps Builtin session-only reads and writes in %s storage through real Sources', async storageScope => { const globalRoot = directory(), customRoot = directory() vi.stubEnv('MNEMON_DATA_DIR', globalRoot) const sessions = [agent('session-1', directory()), agent('session-2', directory())] @@ -48,13 +49,13 @@ describe('default Host scope over the Composable Runtime', () => { const read = createReadHandler(value.live, lifecycle) const write = createWriteHandler(value.live, lifecycle) for (const session of sessions) { - const expected = storageScope === 'workspace' ? join(session.session.header!.cwd!, '.mnemon') : storageScope === 'global' ? globalRoot : customRoot + const expected = createStorageRoot(value.config, session.session.header!.cwd!).effectiveDataDir() expect(value.live.route({ sessionId: session.id })).toMatchObject({ selectedRoot: expected, effectiveRoot: expected, aligned: true }) await expect(write('runtime-memory', { sessionId: session.id, action: 'add', target: 'memory', content: `Memory from ${session.id}` })).resolves.toMatchObject({ ok: true }) await expect(write('document', { sessionId: session.id, action: 'create', title: `Document from ${session.id}`, content: `# ${session.id}` })).resolves.toMatchObject({ ok: true }) } for (const session of sessions) { - const expectedSessions = storageScope === 'workspace' ? [session] : sessions + const expectedSessions = ['workspace', 'workspaces'].includes(storageScope) ? [session] : sessions const memory = await read('runtime-memory', { sessionId: session.id }) expect(memory).toMatchObject({ ok: true, value: { entries: expect.arrayContaining(expectedSessions.map(item => expect.objectContaining({ content: `Memory from ${item.id}` }))) } }) if (memory.ok) expect((memory.value as { entries: unknown[] }).entries).toHaveLength(expectedSessions.length) @@ -118,32 +119,32 @@ describe('default Host scope over the Composable Runtime', () => { live.dispose() expect(() => live.forAgent(parent)).toThrow('disposed') }) - it('resolves inspection and execution workspaces through the Host registry', async () => { + it.each(['workspace', 'workspaces'] as const)('resolves inspection and execution workspaces through the Host registry in %s mode', async storageScope => { const one = directory(), two = directory() - const { graph, extensions } = await fixture({ storageScope: 'workspace' }) + const { graph, extensions } = await fixture({ storageScope }) const workspaces = [{ id: 'one', title: 'One', path: one }, { id: 'two', title: 'Two', path: two }] const registry = { get: (id: string) => workspaces.find(value => value.id === id), list: () => workspaces } satisfies HostWorkspaceRegistry const session = agent('session', one) const agents = { get: (id: string) => id === session.id ? session : undefined, roots: () => [session] } satisfies HostAgentsService const live = new LiveMnemonRuntime(graph, registry, agents, extensions) try { - expect(live.forAgent(session).directory).toBe(join(one, '.mnemon')) - expect(live.forWorkspaceId('two').directory).toBe(join(two, '.mnemon')) + expect(live.forAgent(session).directory).toBe(createStorageRoot(graph.config, one).effectiveDataDir()) + expect(live.forWorkspaceId('two').directory).toBe(createStorageRoot(graph.config, two).effectiveDataDir()) expect(live.route({ workspaceId: 'two', sessionId: session.id })).toMatchObject({ - selectedRoot: join(two, '.mnemon'), effectiveRoot: join(one, '.mnemon'), aligned: false, + selectedRoot: createStorageRoot(graph.config, two).effectiveDataDir(), effectiveRoot: createStorageRoot(graph.config, one).effectiveDataDir(), aligned: false, }) expect(live.route({ workspaceId: 'one', sessionId: session.id }).aligned).toBe(true) expect(() => live.forWorkspaceId('../../private')).toThrow('selected DSH workspace is unavailable') } finally { live.dispose() } }) - it('uses Agent cwd in Headless without a Web workspace registry', async () => { + it.each(['workspace', 'workspaces'] as const)('uses Agent cwd in Headless without a Web workspace registry in %s mode', async storageScope => { const workspace = directory() - const { graph, extensions } = await fixture({ storageScope: 'workspace' }) + const { graph, extensions } = await fixture({ storageScope }) const session = agent('headless', workspace) const live = new LiveMnemonRuntime(graph, undefined, { get: () => session, roots: () => [session] }, extensions) try { - expect(live.forAgent(session).directory).toBe(join(workspace, '.mnemon')) - expect(live.route({ sessionId: session.id })).toMatchObject({ selectedRoot: join(workspace, '.mnemon'), effectiveRoot: join(workspace, '.mnemon'), aligned: true }) + expect(live.forAgent(session).directory).toBe(createStorageRoot(graph.config, workspace).effectiveDataDir()) + expect(live.route({ sessionId: session.id })).toMatchObject({ selectedRoot: createStorageRoot(graph.config, workspace).effectiveDataDir(), effectiveRoot: createStorageRoot(graph.config, workspace).effectiveDataDir(), aligned: true }) } finally { live.dispose() } }) it('preserves one singleton root for global and custom storage', async () => { @@ -170,3 +171,60 @@ describe('default Host scope over the Composable Runtime', () => { } }) }) + +describe('centralized workspace persistence', () => { + it('shares one Source generation for registered aliases of the same workspace', async () => { + const workspace = directory(), alias = join(directory(), 'alias') + symlinkSync(workspace, alias, process.platform === 'win32' ? 'junction' : 'dir') + const workspaces = [{ id: 'real', path: workspace, title: 'Real' }, { id: 'alias', path: alias, title: 'Alias' }] + const f = await compositionFixture({ storageScope: 'workspaces', dataDir: directory() }, { + workspaceRegistry: { get: id => workspaces.find(value => value.id === id), list: () => workspaces }, + }) + fixtures.push(f) + const real = f.live.forWorkspaceId('real'), linked = f.live.forWorkspaceId('alias') + expect(linked).toBe(real) + expect(f.live.forAgent(agent('session', alias))).toBe(real) + await linked.source('runtime').mutate('mutate', { action: 'add', target: 'memory', content: 'Shared canonical workspace' }) + expect((await real.source('runtime').read('snapshot')).entries.map(entry => entry.content)).toEqual(['Shared canonical workspace']) + }) + + it.each(['storage', 'global'] as const)('isolates every Source area while USER.md follows %s scope, and preserves roots across switches', async runtimeUserScope => { + const central = directory(), globalRoot = directory(), one = directory(), two = directory() + vi.stubEnv('MNEMON_DATA_DIR', globalRoot) + const workspaces = [{ id: 'one', path: one, title: 'One' }, { id: 'two', path: two, title: 'Two' }] + const f = await compositionFixture({ storageScope: 'workspaces', dataDir: central, runtimeUserScope }, { + workspaceRegistry: { get: id => workspaces.find(workspace => workspace.id === id), list: () => workspaces }, + }) + fixtures.push(f) + const first = f.live.forWorkspaceId('one'), second = f.live.forWorkspaceId('two') + const firstRuntime = first.source('runtime'), secondRuntime = second.source('runtime') + await firstRuntime.mutate('mutate', { action: 'add', target: 'memory', content: 'Project One only' }) + await firstRuntime.mutate('mutate', { action: 'add', target: 'user', content: 'Prefer compact answers' }) + await first.source('documents').mutate('mutate', { action: 'create', title: 'One design', content: '# One only' }) + const spaces = first.source('memory-spaces') + await spaces.mutate('provider-service-update', { providerId: 'holographic', settings: {}, enabled: true }) + const catalog = await spaces.read<{ items: Array<{ id: string; provider: { id: string } }> }>('body-directory') + const body = catalog.items.find(item => item.provider.id === 'holographic')! + expect(body).toBeDefined() + await spaces.mutate('remember', { memoryBodyId: body.id, content: 'Project One durable sentinel' }) + for (const area of ['runtime', 'data', 'documents', 'state']) expect(existsSync(join(first.directory, area)), area).toBe(true) + const snapshot = await secondRuntime.read('snapshot') + expect(snapshot.entries.map(entry => entry.content)).toEqual(runtimeUserScope === 'global' ? ['Prefer compact answers'] : []) + expect(snapshot.targets.user.markdownPath).toBe(join(runtimeUserScope === 'global' ? globalRoot : second.directory, 'runtime', 'USER.md')) + expect((await second.source('documents').read<{ activeCount: number }>('snapshot')).activeCount).toBe(0) + expect((await second.source('memory-spaces').read<{ items: unknown[] }>('body-directory')).items).toHaveLength(0) + expect(existsSync(join(one, '.mnemon'))).toBe(false) + expect(existsSync(join(two, '.mnemon'))).toBe(false) + const originalMemory = readFileSync(join(first.directory, 'runtime', 'memories.json'), 'utf8') + const originalState = readFileSync(join(first.directory, 'state', 'memory-providers.json'), 'utf8') + const legacy = createRuntimeGraph(resolveConfig({ ...f.config, storageScope: 'custom' }), one, f.extensions) + f.live.swap(legacy) + expect((await f.live.forWorkspaceId('one').source('runtime').read('snapshot')).entries.some(entry => entry.content === 'Project One only')).toBe(false) + f.live.swap(createRuntimeGraph(f.config, one, f.extensions)) + expect((await f.live.forWorkspaceId('one').source('runtime').read('snapshot')).entries.some(entry => entry.content === 'Project One only')).toBe(true) + expect(readFileSync(join(first.directory, 'runtime', 'memories.json'), 'utf8')).toBe(originalMemory) + expect(readFileSync(join(first.directory, 'state', 'memory-providers.json'), 'utf8')).toBe(originalState) + expect(() => f.live.forAgent(agent('missing-cwd', ''))).toThrow('no workspace') + expect(() => f.live.forWorkspaceId('../../other')).toThrow('unavailable') + }) +}) diff --git a/tests/storage-scope.spec.ts b/tests/storage-scope.spec.ts index 9671ff2dc..22fa6c4fd 100644 --- a/tests/storage-scope.spec.ts +++ b/tests/storage-scope.spec.ts @@ -46,13 +46,42 @@ describe('Mnemon storage-domain inventory', () => { ])) }) - it('shows global, workspace, and custom as distinct scopes while marking only the configured one active', () => { + it('shows all four storage modes as distinct scopes while marking only the configured one active', () => { const workspace = temporaryDirectory() const root = temporaryDirectory() const catalog = new StorageScopeInspector({ effectiveDataDir: () => root }, { storageScope: 'custom', dataDir: root }).catalog(workspace) - expect(catalog.scopes.map(scope => scope.kind)).toEqual(['global', 'workspace', 'custom']) + expect(catalog.scopes.map(scope => scope.kind)).toEqual(['global', 'workspace', 'custom', 'workspaces']) expect(catalog.scopes.filter(scope => scope.active)).toEqual([expect.objectContaining({ kind: 'custom', root })]) expect(catalog.scopes.find(scope => scope.kind === 'workspace')?.root).toBe(join(workspace, '.mnemon')) }) }) + +describe('centralized storage roots', () => { + it('uses the configured central root and inventories only the selected workspace subtree', async () => { + const { createStorageRoot } = await import('../src/host/storage-root.ts') + const root = temporaryDirectory(), one = temporaryDirectory(), two = temporaryDirectory() + const config = { storageScope: 'workspaces' as const, dataDir: root } + const first = createStorageRoot(config, one), second = createStorageRoot(config, two) + expect(first.effectiveDataDir()).not.toBe(second.effectiveDataDir()) + const catalog = new StorageScopeInspector(first, config).catalog(one) + expect(catalog.activeRoot).toBe(first.effectiveDataDir()) + expect(catalog.scopes.filter(scope => scope.active)).toEqual([expect.objectContaining({ kind: 'workspaces', root: first.effectiveDataDir() })]) + const active = catalog.scopes.find(scope => scope.active)! + expect(active.areas.map(area => area.path)).toEqual(['runtime', 'data', 'documents', 'state'].map(area => join(first.effectiveDataDir(), area))) + expect(active.areas.every(area => area.status === 'missing')).toBe(true) + }) + it('honors dataDir, then MNEMON_DATA_DIR, then the default home root', async () => { + const { createStorageRoot } = await import('../src/host/storage-root.ts') + const { homedir } = await import('node:os') + const { vi } = await import('vitest') + const workspace = temporaryDirectory(), root = temporaryDirectory() + try { + vi.stubEnv('MNEMON_DATA_DIR', root) + expect(createStorageRoot({ storageScope: 'workspaces' }, workspace).effectiveDataDir()).toMatch(join(root, 'workspaces')) + expect(createStorageRoot({ storageScope: 'workspaces', dataDir: '~/central' }, workspace).effectiveDataDir()).toMatch(join(homedir(), 'central', 'workspaces')) + vi.stubEnv('MNEMON_DATA_DIR', '') + expect(createStorageRoot({ storageScope: 'workspaces' }, workspace).effectiveDataDir()).toMatch(join(homedir(), '.mnemon', 'workspaces')) + } finally { vi.unstubAllEnvs() } + }) +}) diff --git a/tests/workspace-storage.spec.ts b/tests/workspace-storage.spec.ts new file mode 100644 index 000000000..7258a7676 --- /dev/null +++ b/tests/workspace-storage.spec.ts @@ -0,0 +1,61 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createStorageRoot } from '../src/host/storage-root.ts' +import { canonicalWorkspacePath, workspaceStorageId } from '../src/host/workspace-storage.ts' + +const roots: string[] = [] +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'mnemon-workspace-storage-')) + roots.push(root) + const workspace = join(root, 'workspace') + mkdirSync(workspace) + return { root, workspace } +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +describe('built-in centralized workspace layout', () => { + it('resolves a bounded stable directory without writing to either workspace or central root', () => { + const { root, workspace } = fixture() + const dataDir = join(root, 'central') + const id = workspaceStorageId(workspace) + expect(id).toMatch(/^[0-9a-f]{64}$/u) + expect(createStorageRoot({ storageScope: 'workspaces', dataDir }, workspace).effectiveDataDir()).toBe(join(dataDir, 'workspaces', id)) + expect(workspaceStorageId(join(workspace, 'child', '..'))).toBe(id) + expect(readdirSync(workspace)).toEqual([]) + expect(existsSync(dataDir)).toBe(false) + }) + + it('canonicalizes symlink aliases and missing descendants to the same identity', () => { + const { root, workspace } = fixture() + const alias = join(root, 'alias') + symlinkSync(workspace, alias, process.platform === 'win32' ? 'junction' : 'dir') + expect(workspaceStorageId(alias)).toBe(workspaceStorageId(workspace)) + expect(workspaceStorageId(join(alias, 'missing', 'project'))).toBe(workspaceStorageId(join(workspace, 'missing', 'project'))) + expect(canonicalWorkspacePath(join(alias, 'missing'))).toBe(join(canonicalWorkspacePath(workspace), 'missing')) + }) + + it('isolates same-named workspaces and preserves old storage when a workspace moves', () => { + const { root, workspace } = fixture() + const other = join(root, 'other', 'workspace') + mkdirSync(other, { recursive: true }) + expect(workspaceStorageId(other)).not.toBe(workspaceStorageId(workspace)) + const config = { storageScope: 'workspaces' as const, dataDir: join(root, 'central') } + const previous = createStorageRoot(config, workspace).effectiveDataDir() + mkdirSync(previous, { recursive: true }) + writeFileSync(join(previous, 'sentinel'), 'retained') + const moved = join(root, 'renamed') + renameSync(workspace, moved) + expect(createStorageRoot(config, moved).effectiveDataDir()).not.toBe(previous) + expect(readFileSync(join(previous, 'sentinel'), 'utf8')).toBe('retained') + }) + + it('rejects malformed identities and non-directory ancestors while allowing Unicode names', () => { + const { root } = fixture() + for (const workspacePath of ['', '../escape', '/workspace\0']) expect(() => workspaceStorageId(workspacePath)).toThrow() + writeFileSync(join(root, 'file'), 'file') + expect(() => workspaceStorageId(join(root, 'file', 'child'))).toThrow() + expect(workspaceStorageId(resolve(root, '项目 with spaces'))).toMatch(/^[0-9a-f]{64}$/u) + }) +})