diff --git a/.changeset/centralized-workspace-storage.md b/.changeset/centralized-workspace-storage.md new file mode 100644 index 000000000..e5b6ffcac --- /dev/null +++ b/.changeset/centralized-workspace-storage.md @@ -0,0 +1,8 @@ +--- +"dsh-mnemon": patch +"dsh-mnemon-storage-workspaces": patch +--- + +Add the standalone workspaces storage layout plugin, live storage settings and workspace routing so all four memory areas can share a central directory while remaining isolated by workspace. Global USER.md remains optional, and switching scopes preserves old roots. + +新增独立的 workspaces 存储布局插件、实时存储设置与工作区路由,让四类记忆数据集中管理且按工作区隔离。仍可共享全局 USER.md,切换范围保留旧根。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03893dab2..696ba9c9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,7 @@ jobs: - name: Build public plugin SDK entries run: >- pnpm --workspace-concurrency=2 + --filter dsh-mnemon-storage-workspaces --filter dsh-mnemon-source-memory-spaces --filter dsh-mnemon-source-documents --filter dsh-mnemon-source-runtime diff --git a/README.md b/README.md index a80506170..268b2dd4a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ The Starter ships **3 Sources, 1 default Strategy, 3 optional Strategy contribut | [dsh-mnemon-strategy-auto-capture](https://github.com/omdsh-dev/dsh-mnemon/blob/main/plugins/dsh-mnemon-strategy-auto-capture/README.md) | In-turn guidance to retain useful facts | Off | | [dsh-mnemon-strategy-light-context](https://github.com/omdsh-dev/dsh-mnemon/blob/main/plugins/dsh-mnemon-strategy-light-context/README.md) | A shared resident-projection ceiling | Off | | [dsh-mnemon-strategy-scoped](https://github.com/omdsh-dev/dsh-mnemon/blob/main/plugins/dsh-mnemon-strategy-scoped/README.md) | Ordered Source selection and a writable subset | Off | +| [dsh-mnemon-storage-workspaces](https://github.com/omdsh-dev/dsh-mnemon/blob/main/plugins/dsh-mnemon-storage-workspaces/README.md) | Central root with isolated workspace subdirectories | Select `workspaces` | The three enhancements occupy different slots of the default Strategy and can coexist. They still produce one View. Capture is guidance, not an autonomous recorder; a projection ceiling is not token accounting or delta injection; scoped selection does not create storage. diff --git a/README.zh-CN.md b/README.zh-CN.md index acbef5595..2ee37d12d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -83,6 +83,7 @@ Starter 随附 **3 个 Source、1 个默认 Strategy、3 个可选策略贡献 | [dsh-mnemon-strategy-auto-capture](https://github.com/omdsh-dev/dsh-mnemon/blob/main/plugins/dsh-mnemon-strategy-auto-capture/README.md) | 当前回合中主动记录有用事实的指引 | 关闭 | | [dsh-mnemon-strategy-light-context](https://github.com/omdsh-dev/dsh-mnemon/blob/main/plugins/dsh-mnemon-strategy-light-context/README.md) | 所有选中 Source 共享的常驻投影上限 | 关闭 | | [dsh-mnemon-strategy-scoped](https://github.com/omdsh-dev/dsh-mnemon/blob/main/plugins/dsh-mnemon-strategy-scoped/README.md) | 有序选择 Source,并限定可写子集 | 关闭 | +| [dsh-mnemon-storage-workspaces](https://github.com/omdsh-dev/dsh-mnemon/blob/main/plugins/dsh-mnemon-storage-workspaces/README.md) | 集中根目录下按工作区隔离 | 选择 `workspaces` | 三个增强使用默认 Strategy 的不同槽,可以共存,最终仍输出一个 View。主动记录是指引,不是自主记录器;投影上限不是 token 计费或增量注入;范围组合不创建存储。 diff --git a/docs/en/development/README.md b/docs/en/development/README.md index 75504216a..47033d348 100644 --- a/docs/en/development/README.md +++ b/docs/en/development/README.md @@ -14,7 +14,7 @@ pnpm run verify pnpm run verify:plugins ``` -`verify` checks types, deterministic Root builds, independent plugin builds, the full test suite, a real isolated DSH Headless profile and package exports/contents. Independent plugin checks run as separate type/test phases after building all public artifacts. Do not mix `pnpm -r verify` clean builds with tests reading sibling artifacts; use `pnpm verify` for the whole workspace. `verify:plugins` repeats verification **outside** the workspace against semver-installed tarballs and an external Source/Strategy/Provider/Client consumer. It also installs only the packed Root into real DSH, resolving all sixteen official plugins from a loopback registry without workspace links or manifest rewrites, then separately verifies the three shipped enhancements moving from disabled defaults to simultaneous activation. The external consumer compiles its own Strategy extension against the owning Strategy's packed SDK. +`verify` checks types, deterministic Root builds, independent plugin builds, the full test suite, a real isolated DSH Headless profile and package exports/contents. Independent plugin checks run as separate type/test phases after building all public artifacts. Do not mix `pnpm -r verify` clean builds with tests reading sibling artifacts; use `pnpm verify` for the whole workspace. `verify:plugins` repeats verification **outside** the workspace against semver-installed tarballs and an external Source/Strategy/Provider/Client consumer. It also installs only the packed Root into real DSH, resolving all seventeen official plugins from a loopback registry without workspace links or manifest rewrites, then separately verifies the three shipped enhancements moving from disabled defaults to simultaneous activation. The external consumer compiles its own Strategy extension against the owning Strategy's packed SDK. ## Repository ownership @@ -25,6 +25,7 @@ src/ host/ DSH lifecycle, settings, tools, RPC, worker coordination client/ shared workspace, settings, Source-page SDK plugins/ + dsh-mnemon-storage-workspaces/ # stateless Host layout dsh-mnemon-source-runtime/ dsh-mnemon-source-documents/ dsh-mnemon-source-memory-spaces/ @@ -38,7 +39,7 @@ scripts/ reproducible build, artifacts, Headless and Web fixtures cordis.patch.yml default Starter composition ``` -Root owns Core/SDK and the DSH Host/default Starter, not Source storage implementations. Each directory under `plugins/` is a publishable standalone project. The default distribution depends on all sixteen official plugins by public semver; the three enhancement packages are installed by the Starter but their Entries are disabled by default. Source/Strategy peers depend on Core's public SDK, Strategy extensions on their owner's public SDK, and Providers on the Memory Spaces SDK. Peer/development relationships can produce a package-manager cycle warning; production import boundaries are independently checked. +Root owns Core/SDK and the DSH Host/default Starter, not Source storage implementations. Each directory under `plugins/` is a publishable standalone project. The default distribution depends on all seventeen official plugins by public semver; the three enhancement packages are installed by the Starter but their Entries are disabled by default. Source/Strategy peers depend on Core's public SDK, Strategy extensions on their owner's public SDK, and Providers on the Memory Spaces SDK. Peer/development relationships can produce a package-manager cycle warning; production import boundaries are independently checked. No private workspace packages, forwarding controller modules, business bindings or compatibility directory remain. Compatibility means retained user configuration, data and workflows, not retention of historical internal symbols. diff --git a/docs/en/development/releasing.md b/docs/en/development/releasing.md index d7edeb991..5a7d14d64 100644 --- a/docs/en/development/releasing.md +++ b/docs/en/development/releasing.md @@ -15,6 +15,8 @@ Official packages use independent versions. The `dsh-mnemon` Starter is a tested Add a release intent with `pnpm changeset` to every pull request that changes a published artifact. `pnpm release:status` previews the resulting package bumps, while the existing source CI job compares the PR with its exact base revision and rejects any changed published package missing from the new changesets. A dedicated release pull request runs `pnpm release:version`, which applies all intents, synchronizes the exact Starter and external-fixture pins, updates generated Provider version declarations, and refreshes the lockfile. Workspace peer and development relationships use compatible minor-line ranges so an unchanged package's manifest remains unchanged. `pnpm release:check` is read-only and validates the complete mixed-version composition, compatible internal ranges, exact Starter pins, repository metadata, and each package's npm channel. +A feature PR may introduce a new plugin before any existing package version advances; include changesets for that plugin and the Starter. Once any existing package version changes, CI treats the PR as a versioned release and requires consumed changesets, advancing versions and the updated Starter composition. + The manually dispatched npm workflow accepts a full commit SHA that must already equal `main`. It derives the preceding release revision from Git history and selects only packages whose versions advanced. Before requesting any npm credential, it runs the complete workspace and independent-plugin suites, then packs the selected plugins plus the Starter once and records the full composition, both revisions, byte sizes, and SHA-512 integrity. The protected `npm-release` Environment gates Registry writes. After approval, changed plugins publish concurrently within dependency-safe layers; every layer becomes readable before its dependents continue. The workflow installs the complete mixed-version composition through the frozen local Starter, publishes the Starter last, verifies a clean Registry install, and runs the real Registry upgrade before creating the GitHub Release. Interrupted runs are resumable: an existing selected version is reused only when its Registry integrity exactly matches the frozen tarball. A different artifact at the same version stops the release. Stable packages use `latest`; prereleases use their explicit `alpha`, `beta`, or `rc` channel. Unchanged packages must already exist on the Registry and are verified through the complete Starter install, but they are neither packed nor published again. diff --git a/docs/en/guides/capabilities.md b/docs/en/guides/capabilities.md index b9b4a678a..d2cc0d73b 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`) uses the bundled `dsh-mnemon-storage-workspaces` layout plugin: 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..d669623da 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,7 +63,7 @@ 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` | | `cliPath` | auto-discovered | executable path | Explicitly selects the Mnemon CLI | @@ -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` + +The `dsh-mnemon-storage-workspaces` layout plugin ships with the Starter. 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 fad89969e..d34bfd23f 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//`; its independent layout plugin never creates files or changes old roots. Only explicit `runtimeUserScope: global` places USER.md outside that workspace subtree. + + ## Runtime Memory ### Semantics @@ -131,7 +134,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 7b817be2a..e811000b3 100644 --- a/docs/pr-assets/README.md +++ b/docs/pr-assets/README.md @@ -8,6 +8,7 @@ These are dated engineering records, not a gallery of current product features. | Record / 记录 | Scope / 范围 | |---|---| +| [Centralized workspace storage](./issue-189-workspaces/README.md) | Issue #189 plugin, live settings, workspace isolation, restart and real WebUI evidence | | [Creation-time ordering](./issue-202-created-order/README.md) | Issue #202 Runtime/Documents ordering, real WebUI writes and retained screenshots | | [Electron npm CLI — issue #201](./issue-201-electron-cli/README.md) | Real Electron and Node Hosts, npm/native CLI paths, memory operations and version/update evidence | | [Documentation refresh](./documentation-refresh/README.md) | Historical v0.5.2 bilingual media and an explicitly failed 390px resize check | diff --git a/docs/pr-assets/issue-189-workspaces/01-settings-zh.png b/docs/pr-assets/issue-189-workspaces/01-settings-zh.png new file mode 100644 index 000000000..ad8e781d8 Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/01-settings-zh.png differ diff --git a/docs/pr-assets/issue-189-workspaces/02-workspace-a-memory.png b/docs/pr-assets/issue-189-workspaces/02-workspace-a-memory.png new file mode 100644 index 000000000..040de06b9 Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/02-workspace-a-memory.png differ diff --git a/docs/pr-assets/issue-189-workspaces/03-workspace-a-document.png b/docs/pr-assets/issue-189-workspaces/03-workspace-a-document.png new file mode 100644 index 000000000..e578e8c03 Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/03-workspace-a-document.png differ diff --git a/docs/pr-assets/issue-189-workspaces/04-workspace-b-isolated.png b/docs/pr-assets/issue-189-workspaces/04-workspace-b-isolated.png new file mode 100644 index 000000000..d48d7e378 Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/04-workspace-b-isolated.png differ diff --git a/docs/pr-assets/issue-189-workspaces/05-workspace-a-provider.png b/docs/pr-assets/issue-189-workspaces/05-workspace-a-provider.png new file mode 100644 index 000000000..a4fc5adfe Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/05-workspace-a-provider.png differ diff --git a/docs/pr-assets/issue-189-workspaces/06-settings-en.png b/docs/pr-assets/issue-189-workspaces/06-settings-en.png new file mode 100644 index 000000000..f525f5015 Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/06-settings-en.png differ diff --git a/docs/pr-assets/issue-189-workspaces/07-restarted-workspace-a.png b/docs/pr-assets/issue-189-workspaces/07-restarted-workspace-a.png new file mode 100644 index 000000000..99ba507c8 Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/07-restarted-workspace-a.png differ diff --git a/docs/pr-assets/issue-189-workspaces/08-narrow-runtime.png b/docs/pr-assets/issue-189-workspaces/08-narrow-runtime.png new file mode 100644 index 000000000..ff7f48fc2 Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/08-narrow-runtime.png differ diff --git a/docs/pr-assets/issue-189-workspaces/09-narrow-settings-host-limit.png b/docs/pr-assets/issue-189-workspaces/09-narrow-settings-host-limit.png new file mode 100644 index 000000000..555b4c509 Binary files /dev/null and b/docs/pr-assets/issue-189-workspaces/09-narrow-settings-host-limit.png differ diff --git a/docs/pr-assets/issue-189-workspaces/README.md b/docs/pr-assets/issue-189-workspaces/README.md new file mode 100644 index 000000000..ca5628d5f --- /dev/null +++ b/docs/pr-assets/issue-189-workspaces/README.md @@ -0,0 +1,69 @@ +# Issue #189: centralized workspace storage / 集中工作区存储 + +Captured on 2026-09-09 with the implementation at `fe405d3`, rebased onto `main` at `a1c500a`. All retained screenshots use that build. DSH `0.1.2-rc.1`, Starter/storage plugin `0.5.5`, Node `25.1.0`, pnpm `11.19.0`; default browser viewport 1280 × 720, plus a 390 × 844 responsive check. Subsequent commits add evidence, a portable test assertion and release-intent validation; the Host, Source and UI implementation remains at the tested revision. + +截图日期为 2026-09-09,实现为 `fe405d3`,已 rebase 到 `main` 的 `a1c500a`。保留截图全部来自该构建。版本和视口如上;后续提交补充验收记录、跨平台测试断言和发布意图校验,Host、Source 与 UI 实现保持为已验收版本。 + +## Setup / 环境 + +`pnpm e2e:serve` runs the real published DSH Host and the local build with a disposable Profile, global root, workspace A, and loopback model endpoint. A second temporary workspace B and a separate central directory exercise routing. All content is synthetic. Only model replies are scripted; settings persistence, session creation, Source reads/writes and Host restarts are real. The original checkout and personal memory roots are untouched. + +通过 `pnpm e2e:serve` 运行正式 DSH Host 和本地构建,使用一次性 Profile、全局根、工作区 A 和本机模型端点;另外创建临时工作区 B 与集中目录。所有内容均为合成数据,只有模型回复使用固定脚本;设置保存、会话创建、Source 读写和 Host 重启均走真实链路。原 checkout 和个人记忆根未被修改。 + +## Results / 结果 + +| Check / 检查 | Result / 结果 | +|---|---| +| Settings / 设置 | PASS: select Centralized workspaces, save an absolute central root and Global user profile; invalid relative roots block Save. Clear the root to use the default, then restore it. / 选择集中工作区,保存绝对集中根和全局用户档案;相对路径阻止保存,清空可切回默认根,再恢复原根。 | +| Isolation / 隔离 | PASS: A and B retain different project memories; both see the same global USER. A's document and Holographic mapping are absent from B. / A、B 分别保留自己的项目记忆,共享全局 USER;A 的档案与 Holographic 映射不出现在 B。 | +| Scope routing / 范围路由 | PASS: Sidebar follows the inspected registered workspace and displays the session mismatch; Align to conversation restores A. Builtin follows each owning session and hides the picker. / Sidebar 跟随查看工作区并提示与会话不一致;对齐会话恢复 A。Builtin 跟随所属会话,不显示选择器。 | +| Persistence / 持久化 | PASS: custom/default root switching and two Host restarts retain configuration and existing data. Disk assertions confirm all four areas under A's hash, separate B data, global USER, and no project-local `.mnemon`. / 集中根切换及两次 Host 重启后配置和数据保留;磁盘断言确认 A 的四个 area、B 独立数据、全局 USER,项目内没有 `.mnemon`。 | +| Local Provider / 本地 Provider | PASS: the browser enables Holographic and resolves its mapping under A's `state/`. Automated live-runtime tests also write a durable Holographic fact with both USER scopes. / 浏览器启用 Holographic,映射指向 A 的 `state/`;真实运行图测试在两种 USER 范围下另行写入持久事实。 | +| Full verification / 完整验证 | PASS: 823 root tests and 320 plugin tests, type checks, deterministic builds, Headless activation, public entries and package lint. The final Node 22 run enables Native CLI coverage and skips only the Windows smoke test. / 根包 823 项、插件 320 项,类型、确定性构建、Headless、公开入口和包检查通过;最终 Node 22 全量运行启用 Native CLI 测试,只跳过 Windows 专用测试。 | +| Independent artifacts / 独立制品 | PASS: 17 independent plugin repositories and 18 packed artifacts, public SDK consumer, real DSH Starter activation and three optional Strategy plugins together. / 17 个独立插件仓库、18 个发布制品、公开 SDK 消费方、真实 DSH Starter 激活及三个可选 Strategy 同时启用通过。 | +| Runtime compatibility / 运行时兼容 | PASS: full verification on Node `22.19.0` with pnpm `10.13.1`. / Node `22.19.0`、pnpm `10.13.1` 下完整验证全部通过。 | +| Native CLI / 原生 CLI | PASS: installed Mnemon `0.2.7` creates a space, writes, lists, recalls and forgets in a disposable root. Windows-only smoke is not run on macOS. / 本机 Mnemon `0.2.7` 在临时根完成创建空间、写入、列举、召回与删除;macOS 未运行 Windows 专用测试。 | +| Presentation / 界面 | PASS: Chinese/Dark and English/Light; the long English storage option now occupies a full row. Sidebar Runtime remains usable at 390px with the sidebar collapsed. / 中文深色和英文浅色通过,新模式英文名称整行完整显示;收起侧栏后 390px 的 Sidebar 运行时页面可正常浏览。 | + +[Sanitized disk assertions / 脱敏磁盘断言](./disk-assertions.json) + +Release-intent validation also passes for the Starter and new storage plugin. Three regression tests cover new-plugin changesets, consumed release intents, Starter advancement and version-regression rejection. / Starter 与新插件的发布意图校验通过;三项回归覆盖新增插件 changeset、正式发布意图消费、Starter 升级及版本回退拒绝。 + +## Limits / 限制 + +The published DSH settings dialog retains a wide navigation column at 390px, leaving too little space for its settings content; screenshot 09 records this existing Host limitation. In Builtin mode, the Host's conversation resize handle can intercept the center of the Runtime tab; keyboard activation or its right edge works. No DSH source was modified. These checks do not claim mobile settings support. + +正式 DSH 设置弹窗在 390px 下仍保留较宽导航栏,设置内容区过窄;截图 09 记录这一已有 Host 限制。Builtin 中,Host 的会话宽度拖动条可能截获“运行时”Tab 中间位置的点击,键盘或右侧边缘可正常激活。未修改 DSH 源码,也不将本次检查视为手机设置界面验收通过。 + +No browser errors were observed during the feature checks; connection-retry warnings occurred while deliberately restarting the Host. Vitest reports an existing missing-source-map warning from the published upstream UI package. The loopback model is not a quality evaluation of autonomous distillation. The browser created a Holographic mapping without distilling a fact; real provider writes are covered by the live-runtime tests. + +功能检查期间未观察到浏览器错误,主动重启 Host 时出现连接重试警告。Vitest 中正式上游 UI 包存在已有的 source map 缺失警告。本机固定模型不用于评估自主沉淀质量;浏览器只创建 Holographic 映射,真实 Provider 写入由运行图测试覆盖。 + +## Reproduce / 复现 + +```sh +pnpm install --frozen-lockfile +pnpm run verify +node scripts/verify-plugin-artifacts.mjs --skip-build +MNEMON_NATIVE_TEST_CLI=/absolute/path/to/mnemon npx --yes --package=node@22.19.0 --package=pnpm@10.13.1 --call 'pnpm run verify' +MNEMON_NATIVE_TEST_CLI=/absolute/path/to/mnemon pnpm --filter dsh-mnemon-source-memory-spaces exec vitest run tests/native-integration.spec.ts +pnpm e2e:serve +``` + +In the printed disposable workspace, choose the Mnemon E2E preset and create a session. Set `storageScope: workspaces`, an isolated central root and `runtimeUserScope: global` in Settings. Add a project memory, user preference and document in A; enable Holographic. Register B, confirm A's project data is absent, and create a distinct B memory. Switch the inspected workspace, align to the conversation, then exercise Builtin sessions. Clear/restore the central root and restart the fixture with `SIGUSR2`. Confirm data reappears and stop the fixture when finished. + +在输出的一次性工作区选择 Mnemon E2E 预设并创建会话。在设置中启用 `workspaces`、隔离的集中根和全局 USER。为 A 添加项目记忆、用户偏好及档案,并启用 Holographic。登记 B,确认没有 A 的项目数据,再添加不同的 B 记忆。切换查看工作区、对齐会话,然后验证 Builtin 会话。清空和恢复集中根,用 `SIGUSR2` 重启夹具,确认原数据重新出现,结束后停止夹具。 + +## Screenshots / 截图 + +| Evidence / 证据 | Screenshot / 截图 | +|---|---| +| Chinese configuration / 中文配置 | [01](./01-settings-zh.png) | +| A's project memory and global USER / A 项目记忆和全局 USER | [02](./02-workspace-a-memory.png) | +| A's retained document / A 保留的档案 | [03](./03-workspace-a-document.png) | +| B's own memory, shared USER and session mismatch / B 独立记忆、共享 USER 和会话差异提示 | [04](./04-workspace-b-isolated.png) | +| A's local Provider mapping / A 本地 Provider 映射 | [05](./05-workspace-a-provider.png) | +| English configuration / 英文配置 | [06](./06-settings-en.png) | +| Builtin after restart / 重启后的 Builtin | [07](./07-restarted-workspace-a.png) | +| Runtime at 390px / 390px 运行时页面 | [08](./08-narrow-runtime.png) | +| Host settings limitation at 390px / 390px Host 设置限制 | [09](./09-narrow-settings-host-limit.png) | diff --git a/docs/pr-assets/issue-189-workspaces/disk-assertions.json b/docs/pr-assets/issue-189-workspaces/disk-assertions.json new file mode 100644 index 000000000..419e54800 --- /dev/null +++ b/docs/pr-assets/issue-189-workspaces/disk-assertions.json @@ -0,0 +1,44 @@ +{ + "date": "2026-09-09", + "implementationRevision": "fe405d3", + "data": "Synthetic records only; all absolute roots omitted.", + "configuration": { + "storageScope": "workspaces", + "runtimeUserScope": "global", + "dataDir": "" + }, + "workspaceA": { + "root": "/workspaces/8ddcdd34b18e7d81c14f60d3e78ce5a2604b1731c58febdf1ac7c610f0592a3d", + "areas": [ + "runtime", + "data", + "documents", + "state" + ], + "projectMemory": "Issue 189: workspace A project memory stays isolated.", + "activeDocuments": 1, + "holographicMapping": true + }, + "workspaceB": { + "root": "/workspaces/881cdbcb00ec77ac8d2832dfa8af9d999cce46c9ba30b541716b8e292cb96032", + "projectMemory": "Issue 189: workspace B uses its own project memory.", + "activeDocuments": 0, + "holographicMapping": false + }, + "globalUser": { + "root": "/runtime", + "content": "Issue 189: shared user profile prefers concise answers." + }, + "assertions": { + "distinctWorkspaceRoots": true, + "projectMemoryIsolated": true, + "userMemoryOnlyAtGlobalRoot": true, + "documentsIsolated": true, + "providerStateIsolated": true, + "workspaceLocalDotMnemonAbsent": true, + "customAndDefaultRootsRetained": true + }, + "limits": [ + "Browser run creates a Holographic mapping but no Holographic fact; automated live-runtime coverage writes a durable fact through the real local provider." + ] +} diff --git a/docs/zh-CN/development/README.md b/docs/zh-CN/development/README.md index 4d348d16c..15f8715f4 100644 --- a/docs/zh-CN/development/README.md +++ b/docs/zh-CN/development/README.md @@ -14,7 +14,7 @@ pnpm run verify pnpm run verify:plugins ``` -`verify` 包含类型检查、根包确定性构建、独立插件构建、完整测试集、真实隔离 DSH Headless 和包出口/内容验证。独立插件检查在所有公开制品构建完成后分阶段执行。不要用 `pnpm -r verify` 同时清理重建制品和运行读取它们的测试;整个工作区使用 `pnpm verify`。`verify:plugins` 在工作区**外部**,基于 semver 安装的 tarball 重复验证,并测试外部 Source/Strategy/Provider/Client 消费者;还向真实 DSH 仅安装根包 tarball,从 loopback registry 解析全部十六个官方插件,不使用工作区链接或改写 manifest,再单独验证三个随附增强从默认停用到同时启用。外部消费者还通过完整 Strategy 的打包 SDK 编译自己实现的策略贡献。 +`verify` 包含类型检查、根包确定性构建、独立插件构建、完整测试集、真实隔离 DSH Headless 和包出口/内容验证。独立插件检查在所有公开制品构建完成后分阶段执行。不要用 `pnpm -r verify` 同时清理重建制品和运行读取它们的测试;整个工作区使用 `pnpm verify`。`verify:plugins` 在工作区**外部**,基于 semver 安装的 tarball 重复验证,并测试外部 Source/Strategy/Provider/Client 消费者;还向真实 DSH 仅安装根包 tarball,从 loopback registry 解析全部十七个官方插件,不使用工作区链接或改写 manifest,再单独验证三个随附增强从默认停用到同时启用。外部消费者还通过完整 Strategy 的打包 SDK 编译自己实现的策略贡献。 ## 仓库归属 @@ -25,6 +25,7 @@ src/ host/ DSH lifecycle, settings, tools, RPC, worker coordination client/ shared workspace, settings, Source-page SDK plugins/ + dsh-mnemon-storage-workspaces/ # stateless Host layout dsh-mnemon-source-runtime/ dsh-mnemon-source-documents/ dsh-mnemon-source-memory-spaces/ @@ -38,7 +39,7 @@ scripts/ reproducible build, artifacts, Headless and Web fixtures cordis.patch.yml default Starter composition ``` -根包拥有 Core/SDK、DSH Host 和默认 Starter,不拥有 Source 存储实现。`plugins/` 下每个目录都是可独立发布的项目。默认发行包按公开 semver 依赖全部十六个官方插件;三个增强包由 Starter 安装但其 Entry 默认停用。Source/Strategy 通过 peer 使用 Core SDK,策略贡献使用其完整 Strategy 的公开 SDK,Provider 使用 Memory Spaces SDK。peer/开发关系会产生包管理器环依赖提示;生产代码导入边界另有独立检查。 +根包拥有 Core/SDK、DSH Host 和默认 Starter,不拥有 Source 存储实现。`plugins/` 下每个目录都是可独立发布的项目。默认发行包按公开 semver 依赖全部十七个官方插件;三个增强包由 Starter 安装但其 Entry 默认停用。Source/Strategy 通过 peer 使用 Core SDK,策略贡献使用其完整 Strategy 的公开 SDK,Provider 使用 Memory Spaces SDK。peer/开发关系会产生包管理器环依赖提示;生产代码导入边界另有独立检查。 不再保留私有工作区包、控制器转发文件、业务 binding 或 compatibility 目录。兼容指用户配置、数据与使用流程,不是延续历史内部符号。 diff --git a/docs/zh-CN/development/releasing.md b/docs/zh-CN/development/releasing.md index f0aa2b7e0..1caf90752 100644 --- a/docs/zh-CN/development/releasing.md +++ b/docs/zh-CN/development/releasing.md @@ -15,6 +15,8 @@ 凡 PR 会改变发布制品,都使用 `pnpm changeset` 添加发布意图;`pnpm release:status` 可预览版本变化,已有的 source CI job 会把 PR 与其精确 base revision 比较,并拒绝任何已变化却未出现在新增 changeset 中的发布包。专用发布 PR 运行 `pnpm release:version`,一次性应用意图、同步 Starter 与外部 fixture 的精确依赖、更新 Provider 中生成的版本声明并刷新 lockfile。workspace peer 与开发关系使用 minor 线兼容范围,因此未变化包的 manifest 也保持不变。`pnpm release:check` 是只读检查,验证完整的混合版本组合、内部兼容范围、Starter 精确版本、仓库元数据和每个包的 npm 通道。 +功能 PR 可以在任何已有包尚未升级时引入新插件,此时为新插件和 Starter 添加 changeset。只要任一已有包的版本变化,CI 就按版本发布处理,要求 changeset 已被消费、版本前进,以及 Starter 固定组合同步更新。 + 手动触发的 npm workflow 接收完整 commit SHA,且该 SHA 必须已经等于 `main`。它从 Git 历史确定上一个发布 revision,只选择版本发生增长的包。在取得 npm 凭证前,workflow 会运行完整 workspace 与独立插件验证,只打包选中的插件和 Starter,并记录完整组合、两个 revision、字节数与 SHA-512 integrity。受保护的 `npm-release` Environment 是 Registry 写入门禁。批准后,变化的插件在依赖安全的层内并发发布;一层在 Registry 可读后才进入依赖它的下一层。随后通过冻结的本地 Starter 安装完整混合版本组合,最后发布 Starter、验证干净 Registry 安装并运行真实升级,之后才创建 GitHub Release。 中断的流程可以恢复:Registry 中已有的选中版本只有在 integrity 与冻结 tarball 完全一致时才能复用;同版本内容不同会立即终止。正式包使用 `latest`,预发布显式使用 `alpha`、`beta` 或 `rc` 通道。未变化的包必须已存在于 Registry,并通过完整 Starter 安装接受验证,但不会再次打包或发布。 diff --git a/docs/zh-CN/guides/capabilities.md b/docs/zh-CN/guides/capabilities.md index a937b8227..feafe9d93 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`)使用随附的 `dsh-mnemon-storage-workspaces` 布局插件:统一固定根、相互独立的工作区子目录,以及可选的全局 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..0083eb728 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,7 +65,7 @@ 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` | | `cliPath` | 自动发现 | 可执行路径 | 显式指定 Mnemon CLI | @@ -210,6 +210,21 @@ mnemon: 切换设置不会复制、合并或删除条目;改回 `runtimeUserScope: storage` 后,原工作区 USER.md 会重新可见。Mnemon Pack 仍表示一个所选存储根,因此工作区 Pack 不会暗中带入独立的全局 USER.md;重要的全局档案需要单独备份全局根。 +### `workspaces` + +`dsh-mnemon-storage-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 0df62ce58..7ab1b852e 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/<工作区路径哈希>/`;独立布局插件不创建文件或修改旧根。只有显式 `runtimeUserScope: global` 会把 USER.md 放在该工作区子目录之外。 + + ## 运行时记忆 @@ -135,7 +138,7 @@ Documents 保存比单条记忆更完整、又希望快速阅读的项目知识 Documents 的物理共享范围由 `storageScope` 决定: -- `workspace`:通常随项目隔离; +- `workspace` / `workspaces`:通常随项目隔离; - `global` / `custom`:多个工作区可能共享同一个 `documents/index.json`。 因此“项目档案”表示内容类型,不保证天然按工作区物理隔离。当前会话工作区只约束新写入的 `sourcePaths`。 diff --git a/package.json b/package.json index c3633c8ce..ea4775da6 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,8 @@ "dsh-mnemon-strategy-scoped": "0.5.4", "fflate": "^0.8.3", "schemastery": "^3.18.0", - "zod": "^4.4.3" + "zod": "^4.4.3", + "dsh-mnemon-storage-workspaces": "0.5.5" }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", diff --git a/plugins/dsh-mnemon-storage-workspaces/LICENSE b/plugins/dsh-mnemon-storage-workspaces/LICENSE new file mode 100644 index 000000000..e32e112dc --- /dev/null +++ b/plugins/dsh-mnemon-storage-workspaces/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 dsh-external contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/dsh-mnemon-storage-workspaces/README.md b/plugins/dsh-mnemon-storage-workspaces/README.md new file mode 100644 index 000000000..523bcf363 --- /dev/null +++ b/plugins/dsh-mnemon-storage-workspaces/README.md @@ -0,0 +1,30 @@ +# dsh-mnemon-storage-workspaces + +A stateless storage layout plugin for the Mnemon Starter. Select **Settings → Memory System → Memory scope → Centralized · isolated by workspace**. The optional central root is next to the scope selector. + +```yaml +mnemon: + storageScope: workspaces + dataDir: ~/central-memory # optional; otherwise MNEMON_DATA_DIR or ~/.mnemon + runtimeUserScope: global # optional; defaults to storage +``` + +The Starter consumes the package's default `MemoryStoragePlugin` export through the public `dsh-mnemon/extension-sdk` contract. It is a Host layout plugin, with no separate Cordis Entry or Source/Strategy contribution. Selecting `workspaces` activates its resolver; the existing modes retain their layouts. No extra enable switch is needed. + +The layout is `/workspaces//`. Runtime, Documents, Memory Spaces and state share that workspace root. `runtimeUserScope: global` keeps only USER.md at the global root. Provider services with remote/global namespaces retain their own sharing semantics. + +Existing path aliases resolve through `realpath`; missing descendants resolve through their closest existing ancestor. IDs contain 64 hexadecimal characters, avoiding workspace names and filesystem-reserved filename characters. Moving or renaming the workspace selects a new ID. Switching modes never moves, merges or deletes old roots. Back up the whole central directory to include every workspace; a Mnemon Pack exports only the selected workspace root. + +The resolver accepts an absolute, trusted Host workspace path and absolute central directory, performs no writes and rejects malformed paths. Build and test independently with `pnpm install && pnpm verify`; unpublished SDK changes can be verified using the repository's packed-artifact harness. + +## 简体中文 + +这是 Mnemon Starter 的无状态存储布局插件。在“设置 → 记忆系统 → 记忆范围”中选择“集中存储 · 按工作区隔离”,可在选择器旁配置集中根目录。上方 YAML 的 `dataDir` 可省略,按 `MNEMON_DATA_DIR`、`~/.mnemon` 的顺序取默认值;`runtimeUserScope` 默认为 `storage`。 + +Starter 通过公开的 `dsh-mnemon/extension-sdk` 契约使用本包默认导出的 `MemoryStoragePlugin`。插件属于 Host 目录布局,不单独增加 Cordis Entry、Source 或 Strategy;选择 `workspaces` 即启用,不需要额外开关。 + +目录为 `<集中根>/workspaces/<规范工作区路径的 SHA-256>/`,Runtime、Documents、Memory Spaces 和 state 都使用该工作区根。`runtimeUserScope: global` 只让 USER.md 保持全局。远端或全局 Provider 命名空间仍遵循自身共享语义。 + +现存路径用 `realpath` 归一化符号链接,不存在的后缀从最近存在的祖先解析。ID 固定为 64 位十六进制字符,不包含工作区名称或文件名保留字符。移动或重命名工作区会选择新 ID。切换模式不会移动、合并或删除旧根。备份整个集中目录可覆盖所有工作区;Mnemon Pack 仍只导出当前工作区根。 + +解析器只接受 Host 提供的可信绝对工作区路径和绝对集中根,拒绝非法路径,不写入任何文件。独立执行 `pnpm install && pnpm verify`;尚未发布的 SDK 改动使用仓库的打包制品验证夹具。 diff --git a/plugins/dsh-mnemon-storage-workspaces/package.json b/plugins/dsh-mnemon-storage-workspaces/package.json new file mode 100644 index 000000000..f402cca86 --- /dev/null +++ b/plugins/dsh-mnemon-storage-workspaces/package.json @@ -0,0 +1,51 @@ +{ + "name": "dsh-mnemon-storage-workspaces", + "version": "0.5.5", + "description": "Fixed-root, per-workspace storage layout plugin for the Mnemon Starter.", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=20" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib", + "README.md", + "LICENSE" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsdown", + "test": "vitest run", + "verify": "pnpm typecheck && pnpm test && pnpm build" + }, + "peerDependencies": { + "dsh-mnemon": "^0.5.5" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "dsh-mnemon": "^0.5.5", + "tsdown": "^0.22.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" + }, + "publishConfig": { + "access": "public", + "tag": "latest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/omdsh-dev/dsh-mnemon.git", + "directory": "plugins/dsh-mnemon-storage-workspaces" + }, + "homepage": "https://github.com/omdsh-dev/dsh-mnemon/tree/main/plugins/dsh-mnemon-storage-workspaces#readme", + "bugs": { + "url": "https://github.com/omdsh-dev/dsh-mnemon/issues" + } +} diff --git a/plugins/dsh-mnemon-storage-workspaces/src/index.ts b/plugins/dsh-mnemon-storage-workspaces/src/index.ts new file mode 100644 index 000000000..1a74e0fef --- /dev/null +++ b/plugins/dsh-mnemon-storage-workspaces/src/index.ts @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto' +import { realpathSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { defineMemoryStoragePlugin } from 'dsh-mnemon/extension-sdk' + +/** Resolve aliases even when the final workspace directory does not exist yet. */ +export function canonicalWorkspacePath(workspacePath: string): string { + if (!isAbsolute(workspacePath) || workspacePath.includes('\0')) throw new Error('Workspace path must be absolute') + const absolute = resolve(workspacePath) + let parent = absolute + 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 + } + } +} + +/** Path-based identity: a rename or move deliberately selects a new directory. */ +export function workspaceStorageId(workspacePath: string): string { + return createHash('sha256').update(canonicalWorkspacePath(workspacePath)).digest('hex') +} + +export default defineMemoryStoragePlugin({ + apiVersion: 'dsh-mnemon/storage/v1', + packageName: 'dsh-mnemon-storage-workspaces', + scope: 'workspaces', + resolveDirectory({ baseDirectory, workspacePath }) { + if (!isAbsolute(baseDirectory) || baseDirectory.includes('\0')) throw new Error('Storage base directory must be absolute') + return join(baseDirectory, 'workspaces', workspaceStorageId(workspacePath)) + }, +}) diff --git a/plugins/dsh-mnemon-storage-workspaces/tests/storage.spec.ts b/plugins/dsh-mnemon-storage-workspaces/tests/storage.spec.ts new file mode 100644 index 000000000..88af8d7a8 --- /dev/null +++ b/plugins/dsh-mnemon-storage-workspaces/tests/storage.spec.ts @@ -0,0 +1,60 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, 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 storage, { canonicalWorkspacePath, workspaceStorageId } from '../src/index.ts' + +const roots: string[] = [] +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'mnemon-storage-plugin-')) + 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('centralized workspace storage plugin', () => { + it('returns a deterministic, bounded filename under the central root without writing files', () => { + const { root, workspace } = fixture() + const baseDirectory = join(root, 'memory') + const id = workspaceStorageId(workspace) + expect(id).toMatch(/^[0-9a-f]{64}$/u) + expect(storage).toMatchObject({ apiVersion: 'dsh-mnemon/storage/v1', scope: 'workspaces' }) + expect(storage.resolveDirectory({ baseDirectory, workspacePath: workspace })).toBe(join(baseDirectory, 'workspaces', id)) + expect(workspaceStorageId(join(workspace, 'child', '..'))).toBe(id) + expect(readdirSync(workspace)).toEqual([]) + expect(existsSync(baseDirectory)).toBe(false) + }) + it('resolves symlink aliases including 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 changes identity on a move without touching old storage', () => { + const { root, workspace } = fixture() + const other = join(root, 'other', 'workspace') + mkdirSync(other, { recursive: true }) + expect(workspaceStorageId(other)).not.toBe(workspaceStorageId(workspace)) + const previous = storage.resolveDirectory({ baseDirectory: root, workspacePath: workspace }) + mkdirSync(previous, { recursive: true }) + writeFileSync(join(previous, 'sentinel'), 'retained') + const moved = join(root, 'renamed') + renameSync(workspace, moved) + expect(storage.resolveDirectory({ baseDirectory: root, workspacePath: moved })).not.toBe(previous) + expect(readdirSync(previous)).toEqual(['sentinel']) + }) + it('rejects relative paths, embedded nulls and non-directory ancestors', () => { + const { root } = fixture() + for (const workspacePath of ['', '../escape', '/workspace\0']) { + expect(() => storage.resolveDirectory({ baseDirectory: root, workspacePath })).toThrow() + } + expect(() => storage.resolveDirectory({ baseDirectory: 'relative', workspacePath: root })).toThrow('absolute') + writeFileSync(join(root, 'file'), 'file') + expect(() => workspaceStorageId(join(root, 'file', 'child'))).toThrow() + expect(workspaceStorageId(resolve(root, '项目 with spaces'))).toMatch(/^[0-9a-f]{64}$/u) + }) +}) diff --git a/plugins/dsh-mnemon-storage-workspaces/tsconfig.json b/plugins/dsh-mnemon-storage-workspaces/tsconfig.json new file mode 100644 index 000000000..badeba7ca --- /dev/null +++ b/plugins/dsh-mnemon-storage-workspaces/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "tsdown.config.ts", + "vitest.config.ts" + ] +} diff --git a/plugins/dsh-mnemon-storage-workspaces/tsdown.config.ts b/plugins/dsh-mnemon-storage-workspaces/tsdown.config.ts new file mode 100644 index 000000000..18b398825 --- /dev/null +++ b/plugins/dsh-mnemon-storage-workspaces/tsdown.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], outDir: 'lib', format: 'esm', platform: 'node', + target: 'es2024', dts: true, clean: true, fixedExtension: false, deps: { neverBundle: true }, +}) diff --git a/plugins/dsh-mnemon-storage-workspaces/vitest.config.ts b/plugins/dsh-mnemon-storage-workspaces/vitest.config.ts new file mode 100644 index 000000000..5a6724c22 --- /dev/null +++ b/plugins/dsh-mnemon-storage-workspaces/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ test: { environment: 'node' } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc49f09c9..f14c1f992 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: dsh-mnemon-source-runtime: specifier: 0.5.4 version: link:plugins/dsh-mnemon-source-runtime + dsh-mnemon-storage-workspaces: + specifier: 0.5.5 + version: link:plugins/dsh-mnemon-storage-workspaces dsh-mnemon-strategy-auto-capture: specifier: 0.5.4 version: link:plugins/dsh-mnemon-strategy-auto-capture @@ -571,6 +574,24 @@ importers: specifier: ^3.2.0 version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jsdom@26.1.0)(lightningcss@1.33.0)(yaml@2.9.0) + plugins/dsh-mnemon-storage-workspaces: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + dsh-mnemon: + specifier: ^0.5.5 + version: link:../.. + tsdown: + specifier: ^0.22.0 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jsdom@26.1.0)(lightningcss@1.33.0)(yaml@2.9.0) + plugins/dsh-mnemon-strategy-auto-capture: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/check-release-intent.mjs b/scripts/check-release-intent.mjs index 03d315df7..0b938e5f8 100644 --- a/scripts/check-release-intent.mjs +++ b/scripts/check-release-intent.mjs @@ -40,6 +40,14 @@ export function assertVersionedReleaseIntent(plan, paths, options, pendingChange return new Set(plan.releasePackages.map(item => item.manifest.name)) } +export function createReleaseIntentPlan(packages, baseVersions) { + // A new plugin in a feature PR still needs a changeset, not consumed release + // versions. Once any existing package changes version, retain all release gates. + const versioned = packages.some(({ manifest }) => baseVersions.has(manifest.name) + && baseVersions.get(manifest.name) !== manifest.version) + return createReleasePlan(packages, versioned ? { baseVersions } : {}) +} + async function readChangesetStatusSince(baseRevision) { const directory = await mkdtemp(join(tmpdir(), 'mnemon-changeset-status-')) const output = join(directory, 'status.json') @@ -68,11 +76,11 @@ async function main() { const packages = await readReleasePackages() const baseVersions = await readReleaseVersionsAtRevision(packages, baseRevision) - const plan = createReleasePlan(packages, { baseVersions }) + const plan = createReleaseIntentPlan(packages, baseVersions) const paths = await readChangedPaths(baseRevision, revision) const ignoredPackageJson = await devOnlyManifestChanges(plan, paths, baseRevision, revision) const changedPackages = publicationInputsChanged(plan, paths, { ignoredPackageJson }) - if (plan.releasePackages.length > 0) { + if (plan.selectionComputed && plan.releasePackages.length > 0) { const covered = assertVersionedReleaseIntent(plan, paths, { ignoredPackageJson }, await readPendingChangesets()) console.log(`Verified consumed changesets and version coverage for release packages: ${[...covered].sort().join(', ')}.`) return diff --git a/scripts/fixtures/plugin-consumer/tests/composition.spec.ts b/scripts/fixtures/plugin-consumer/tests/composition.spec.ts index 1b482911c..ab4686f7b 100644 --- a/scripts/fixtures/plugin-consumer/tests/composition.spec.ts +++ b/scripts/fixtures/plugin-consumer/tests/composition.spec.ts @@ -68,7 +68,7 @@ describe('external consumer of packed artifacts', () => { it('imports every declared Node entry from installed packages, not repository sources', async () => { const names: string[] = JSON.parse(readFileSync(new URL('../artifacts.json', import.meta.url), 'utf8')) const require = createRequire(import.meta.url) - expect(names).toHaveLength(17) + expect(names).toHaveLength(18) for (const name of names) { const manifest = JSON.parse(readFileSync(require.resolve(name + '/package.json'), 'utf8')) for (const subpath of Object.keys(manifest.exports)) { diff --git a/scripts/release.mjs b/scripts/release.mjs index 380ae2d6d..e14e86660 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -13,7 +13,7 @@ const execute = promisify(execFile) const defaultRegistry = 'https://registry.npmjs.org' const manifestName = 'release-manifest.json' const starterName = 'dsh-mnemon' -const internalName = /^dsh-mnemon-(?:source|strategy|provider)-[a-z0-9-]+$/u +const internalName = /^dsh-mnemon-(?:source|strategy|provider|storage)-[a-z0-9-]+$/u const releaseVersion = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(alpha|beta|rc)\.(?:0|[1-9]\d*))?$/u function distTagForVersion(version) { @@ -206,7 +206,7 @@ export function publicationInputsChanged(plan, paths, { ignoredPackageJson = new const changed = new Set() const rootInputs = /^(?:package\.json|cordis\.patch\.yml|README(?:\.zh-CN)?\.md|LICENSE|SECURITY\.md|THIRD_PARTY_NOTICES\.md|tsconfig(?:\.types)?\.json|tsdown\.config\.ts|scripts\/link-bundle-declarations\.mjs|src\/|lib\/)/u for (const path of paths) { - const plugin = /^plugins\/(dsh-mnemon-(?:source|strategy|provider)-[a-z0-9-]+)\/(.+)$/u.exec(path) + const plugin = /^plugins\/(dsh-mnemon-(?:source|strategy|provider|storage)-[a-z0-9-]+)\/(.+)$/u.exec(path) if (plugin) { const [, name, packagePath] = plugin if (packageNames.has(name) && !(packagePath === 'package.json' && ignoredPackageJson.has(name)) 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 88124df2d..5b39b8bfc 100644 --- a/src/host/rpc.ts +++ b/src/host/rpc.ts @@ -1,3 +1,4 @@ +import { isWorkspaceStorageScope } from './protocol.ts' import { isDefaultSourceInstance } from './protocol.ts' import type { HostConnectionHandle, HostRpcAuthority, HostRpcHandler, RpcResult } from './dsh.ts' import type { MnemonLifecycle } from './lifecycle.ts' @@ -207,7 +208,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..6d3963634 100644 --- a/src/host/runtime.ts +++ b/src/host/runtime.ts @@ -1,4 +1,4 @@ -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' @@ -190,7 +190,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 +200,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 +220,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) diff --git a/src/host/storage-root.ts b/src/host/storage-root.ts index 3b8f9d71d..6976bfb83 100644 --- a/src/host/storage-root.ts +++ b/src/host/storage-root.ts @@ -2,6 +2,7 @@ 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 workspacesStorage from 'dsh-mnemon-storage-workspaces' export interface StorageRoot { effectiveDataDir(): string @@ -11,8 +12,12 @@ export interface StorageRoot { /** The default product's location preference; individual Sources own storage. */ 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) + : config.storageScope === 'custom' ? config.dataDir! + : (config.storageScope === workspacesStorage.scope ? config.dataDir : undefined) ?? (process.env.MNEMON_DATA_DIR?.trim() || '~/.mnemon') + const baseDirectory = resolve(value === '~' ? homedir() : value.startsWith('~/') ? join(homedir(), value.slice(2)) : value) + const directory = config.storageScope === workspacesStorage.scope + ? workspacesStorage.resolveDirectory({ baseDirectory, workspacePath: resolve(workspaceRoot ?? process.cwd()) }) + : baseDirectory 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/sdk/index.ts b/src/sdk/index.ts index 8b1fc09bd..203d69d8a 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -39,3 +39,6 @@ export type { MemoryViewSpec, MemoryViewGuidance, } from "../core/contracts/index.ts" + +export { defineMemoryStoragePlugin } from './storage.ts' +export type { MemoryStoragePlugin, MemoryStorageLocation } from './storage.ts' diff --git a/src/sdk/storage.ts b/src/sdk/storage.ts new file mode 100644 index 000000000..083723887 --- /dev/null +++ b/src/sdk/storage.ts @@ -0,0 +1,20 @@ +export interface MemoryStorageLocation { + /** Absolute central directory, after the Host resolves dataDir, environment and home. */ + baseDirectory: string + /** Trusted Host workspace path, never a client-supplied filesystem path. */ + workspacePath: string +} + +/** Stateless Host layout plugin. Sources continue to own every persisted area. */ +export interface MemoryStoragePlugin { + apiVersion: 'dsh-mnemon/storage/v1' + packageName: string + scope: string + resolveDirectory(location: MemoryStorageLocation): string +} + +export function defineMemoryStoragePlugin(plugin: MemoryStoragePlugin): Readonly { + if (plugin.apiVersion !== 'dsh-mnemon/storage/v1' || !/^[a-z][a-z0-9-]*$/u.test(plugin.scope) + || !plugin.packageName.trim() || typeof plugin.resolveDirectory !== 'function') throw new Error('Invalid memory storage plugin') + return Object.freeze({ ...plugin }) +} diff --git a/tests/client-presentation.spec.ts b/tests/client-presentation.spec.ts index aa78fce38..e317fce43 100644 --- a/tests/client-presentation.spec.ts +++ b/tests/client-presentation.spec.ts @@ -25,8 +25,8 @@ describe('default Source presentation migration', () => { }) 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..ac5b2e7f6 100644 --- a/tests/fixtures/presentation-baseline.json +++ b/tests/fixtures/presentation-baseline.json @@ -61,5 +61,15 @@ "classes": 29, "classesSha256": "c2eeeb8d1e00063d29bfe2949339820184f3e8b22f631c362edd85aad8bfe573" } + }, + "centralizedWorkspaces": { + "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..7753f5bd2 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 } 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,45 @@ describe('default Host scope over the Composable Runtime', () => { } }) }) + +describe('centralized workspace persistence', () => { + 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/plugin-repository-boundary.spec.ts b/tests/plugin-repository-boundary.spec.ts index db76aadbe..910b201fc 100644 --- a/tests/plugin-repository-boundary.spec.ts +++ b/tests/plugin-repository-boundary.spec.ts @@ -46,6 +46,7 @@ describe('standalone plugin repository boundary', () => { ]) expect(pluginNames.filter(name => name.startsWith('dsh-mnemon-strategy-'))).toEqual([...threeTierExtensions, threeTierOwner].sort()) expect(pluginNames.filter(name => name.startsWith('dsh-mnemon-provider-'))).toHaveLength(9) + expect(pluginNames.filter(name => name.startsWith('dsh-mnemon-storage-'))).toEqual(['dsh-mnemon-storage-workspaces']) }) for (const name of pluginNames) it(`${name} owns its build, tests and declared public dependencies`, () => { diff --git a/tests/release.spec.mjs b/tests/release.spec.mjs index cc67b537f..f80edd863 100644 --- a/tests/release.spec.mjs +++ b/tests/release.spec.mjs @@ -17,7 +17,7 @@ import { writeReleaseManifest, } from '../scripts/release.mjs' import { syncReleaseMetadata } from '../scripts/sync-release-metadata.mjs' -import { assertReleaseIntentCoverage } from '../scripts/check-release-intent.mjs' +import { assertReleaseIntentCoverage, assertVersionedReleaseIntent, createReleaseIntentPlan } from '../scripts/check-release-intent.mjs' const root = fileURLToPath(new URL('../', import.meta.url)) const revision = '1'.repeat(40) @@ -69,7 +69,7 @@ describe('selective, channel-safe official release', () => { it('validates every real package without requiring one shared version', async () => { const packages = await readReleasePackages(root) const plan = createReleasePlan(packages) - expect(plan.composition).toHaveLength(17) + expect(plan.composition).toHaveLength(18) expect(plan.distTag).toBe('latest') expect(plan.composition.at(-1).manifest.name).toBe('dsh-mnemon') for (const { directory, manifest } of packages.filter(packageItem => packageItem.manifest.name.startsWith('dsh-mnemon-provider-'))) { @@ -187,6 +187,42 @@ describe('selective, channel-safe official release', () => { ])).not.toThrow() }) + it('requires changeset coverage when a feature PR introduces an unversioned storage plugin', () => { + const packages = fixture() + const baseVersions = previousVersions(packages) + const storage = item('/storage', 'dsh-mnemon-storage-workspaces', '0.5.0', { + peerDependencies: { 'dsh-mnemon': '^0.5.0' }, + }) + packages.push(storage) + packages[0].manifest.dependencies[storage.manifest.name] = storage.manifest.version + const plan = createReleaseIntentPlan(packages, baseVersions) + expect(plan.selectionComputed).toBe(false) + const changed = publicationInputsChanged(plan, ['package.json', 'plugins/dsh-mnemon-storage-workspaces/src/index.ts']) + expect(changed).toEqual(new Set(['dsh-mnemon', storage.manifest.name])) + expect(() => assertReleaseIntentCoverage(changed, [{ name: 'dsh-mnemon', type: 'patch' }])).toThrow(storage.manifest.name) + expect(() => assertReleaseIntentCoverage(changed, [...changed].map(name => ({ name, type: 'patch' })))).not.toThrow() + }) + + it('selects new plugins once release versions advance and requires consumed changesets', () => { + const packages = fixture({ secondProviderVersion: '0.5.0' }) + const baseVersions = previousVersions(packages, { 'dsh-mnemon': '0.5.1' }) + baseVersions.delete('dsh-mnemon-provider-second') + const plan = createReleaseIntentPlan(packages, baseVersions) + expect(plan.selectionComputed).toBe(true) + expect(plan.releasePackages.map(item => item.manifest.name)).toEqual(['dsh-mnemon-provider-second', 'dsh-mnemon']) + const paths = ['package.json', 'plugins/dsh-mnemon-provider-second/src/index.ts'] + expect(() => assertVersionedReleaseIntent(plan, paths, {}, ['pending-plugin.md'])).toThrow('pending changesets') + expect(() => assertVersionedReleaseIntent(plan, paths, {}, [])).not.toThrow() + }) + + it('keeps Starter advancement and version-regression guards in intent validation', () => { + const packages = fixture() + expect(() => createReleaseIntentPlan(packages, previousVersions(packages, { 'dsh-mnemon-provider-example': '0.5.0' }))) + .toThrow('Starter version must advance') + expect(() => createReleaseIntentPlan(packages, previousVersions(packages, { 'dsh-mnemon': '0.5.3' }))) + .toThrow('version must advance') + }) + it('packs only selected versions and freezes the full mixed-version composition', async () => { const packages = fixture() const plan = createReleasePlan(packages, { baseVersions: previousVersions(packages, { diff --git a/tests/storage-scope.spec.ts b/tests/storage-scope.spec.ts index 9671ff2dc..04c702c75 100644 --- a/tests/storage-scope.spec.ts +++ b/tests/storage-scope.spec.ts @@ -51,8 +51,37 @@ describe('Mnemon storage-domain inventory', () => { 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/tsconfig.json b/tsconfig.json index 08352c1be..13650eebb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -110,6 +110,9 @@ ], "dsh-mnemon-source-memory-spaces/native-cli": [ "./plugins/dsh-mnemon-source-memory-spaces/src/native-cli.ts" + ], + "dsh-mnemon-storage-workspaces": [ + "./plugins/dsh-mnemon-storage-workspaces/src/index.ts" ] }, "resolveJsonModule": true diff --git a/vitest.config.ts b/vitest.config.ts index 1110deff4..378a71616 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ 'dsh-mnemon-source-runtime/contracts': fileURLToPath(new URL('./plugins/dsh-mnemon-source-runtime/src/contracts.ts', import.meta.url)), 'dsh-mnemon-source-documents/contracts': fileURLToPath(new URL('./plugins/dsh-mnemon-source-documents/src/contracts.ts', import.meta.url)), 'dsh-mnemon-source-memory-spaces/contracts': fileURLToPath(new URL('./plugins/dsh-mnemon-source-memory-spaces/src/contracts.ts', import.meta.url)), + 'dsh-mnemon-storage-workspaces': fileURLToPath(new URL('./plugins/dsh-mnemon-storage-workspaces/src/index.ts', import.meta.url)), 'dsh-mnemon-source-runtime': fileURLToPath(new URL('./plugins/dsh-mnemon-source-runtime/src/index.ts', import.meta.url)), 'dsh-mnemon-source-documents': fileURLToPath(new URL('./plugins/dsh-mnemon-source-documents/src/index.ts', import.meta.url)), 'dsh-mnemon-source-memory-spaces': fileURLToPath(new URL('./plugins/dsh-mnemon-source-memory-spaces/src/index.ts', import.meta.url)),