diff --git a/.changeset/contracts-one-owner.md b/.changeset/contracts-one-owner.md new file mode 100644 index 00000000..0a9f12a4 --- /dev/null +++ b/.changeset/contracts-one-owner.md @@ -0,0 +1,7 @@ +--- +"dsh-mnemon": patch +"dsh-mnemon-source-memory-spaces": patch +"dsh-mnemon-source-runtime": patch +--- + +Internal cleanups; nothing you see or store changes, and every stored setting validates as before. dsh-mnemon takes the values it shares with Memory Spaces and runtime memory from those components instead of keeping copies (memory categories and sources, edge types, the embedding defaults, the runtime memory limits and the placement capabilities a persistence strategy can require), lists the endpoints its Host serves once for both the Host and the WebUI, and checks the Git sync settings in the WebUI and on the Host with one set of rules. For plugin authors, the Memory Spaces `./contracts` entry now exports `MEMORY_PLACEMENT_CAPABILITIES`, from which `MemoryPlacementCapability` is derived, and the runtime memory `./contracts` entry exports `RUNTIME_MEMORY_LIMIT_MAX_BYTES`, the largest limit its USER.md and MEMORY.md take. diff --git a/docs/en/reference/interfaces.md b/docs/en/reference/interfaces.md index 44981408..0a513dfb 100644 --- a/docs/en/reference/interfaces.md +++ b/docs/en/reference/interfaces.md @@ -167,7 +167,7 @@ RPC is an internal Host-to-client bridge, not a stable external HTTP API. Pages | `/dsh-mnemon-write` | Every other mutation | Needs `remoteAccess: trusted-host` | | `/dsh-mnemon-pack` | Backup export and import | Needs `remoteAccess: trusted-host` | | `/dsh-mnemon-sync` | Git repository sync: configuration, push, preview and pull | Needs `remoteAccess: trusted-host` | -| `/dsh-mnemon-review` | Reconciliation proposals, review opinions and applying an accepted plan | Reads allowed; `reconcile` and `apply` need `remoteAccess: trusted-host` | +| `/dsh-mnemon-review` | Reconciliation proposals, review opinions and applying an accepted plan | Needs `remoteAccess: trusted-host` | | `/dsh-mnemon-settings` | Host and interface settings | `get` allowed; `mutate` needs `remoteAccess: trusted-host` | | `/dsh-mnemon-view` | Memory composition reads | Allowed | | `/dsh-mnemon-view-settings` | Saving memory composition, installing a component | Needs `remoteAccess: trusted-host` | diff --git a/docs/pr-assets/README.md b/docs/pr-assets/README.md index 459bb81a..cbbc7f53 100644 --- a/docs/pr-assets/README.md +++ b/docs/pr-assets/README.md @@ -12,6 +12,7 @@ For new evaluations, commit the reusable harness and workload generator to [`scr | Record / 记录 | Scope / 范围 | |---|---| +| [One owner for shared values, wire types and sync checks](./contracts-one-owner/README.md) / [中文](./contracts-one-owner/README.zh-CN.md) | The Starter takes Memory Spaces' lists and embedding defaults, the runtime memory limits and the placement capabilities from those components' contracts, three RPC results move into the browser-safe protocol, one typed list names every endpoint the Host serves, and the WebUI and the Host check the Git sync settings by one set of rules; a harness shows the same verdicts and messages as main / Starter 改从记忆空间与运行时记忆的 contracts 取用记忆空间的列表与嵌入默认值、运行时记忆上限与放置能力,三个 RPC 结果移入可在浏览器使用的协议,一份带类型的列表列出 Host 提供的全部端点,WebUI 与 Host 用同一套规则检查 Git 同步设置;验证脚本证明判断与提示信息与 main 相同 | | [Client modules split along their seams](./client-module-splits/README.md) / [中文](./client-module-splits/README.zh-CN.md) | The Memory System workbench, the Git sync section and Memory Spaces' pages split into a module per page, section and dialog, and the workbench's status, Source catalog and navigation into three hooks; the built bundles hold the same statements and run the same effects in the same order, and every public entry exports the same names / 记忆系统工作台、Git 同步区块与记忆空间页面拆成每个页面、区块与对话框各自的模块,工作台的状态、Source 目录与导航移入三个 hook;构建产物包含同样的语句、按同样的顺序运行同样的 effect,每个公开入口导出同样的名称 | | [The v0.5.32 WebUI gallery, captured on DSH 0.2.1](./webui-gallery-v0532/README.md) / [中文](./webui-gallery-v0532/README.zh-CN.md) | The documentation's screens and recordings captured again with the live model on DSH 0.2.1-rc.2 as webui-v0.5.32, the current documentation pointed at them, and three behaviors found while capturing added to the plan as items 12 to 14 / 在 DSH 0.2.1-rc.2 上由真实模型重新采集文档的截图与录制(webui-v0.5.32),当前文档改用新图集,采集中发现的三个行为作为第 12 至 14 项加入计划 | | [Small cleanups](./small-cleanups/README.md) / [中文](./small-cleanups/README.zh-CN.md) | One module for the shipped components and one accessor for the main Strategy id; Memory Spaces exports the Native Provider id and a space's name and description limits, and its code, its client and the Starter's client use them; the retired `toolviews` preference leaves the resolved config; a compatibility register lists each piece of compatibility code and when it can go; nothing users or the model see changes / 随附组件由一个模块列出,主策略 id 通过一个入口读取;记忆空间导出 Native Provider id 与空间名称、描述的上限,记忆空间自身、其客户端与 Starter 的客户端都改用它们;已弃用的 `toolviews` 偏好不再出现在解析后的配置中;兼容登记表列出每项兼容代码及其移除条件;用户和模型看到的内容不变 | diff --git a/docs/pr-assets/contracts-one-owner/README.md b/docs/pr-assets/contracts-one-owner/README.md new file mode 100644 index 00000000..4f62e7d9 --- /dev/null +++ b/docs/pr-assets/contracts-one-owner/README.md @@ -0,0 +1,108 @@ +# One owner for shared values, wire types and sync checks + +[简体中文](./README.zh-CN.md) + +The technical-debt audit after v0.5.31 found values the Starter's Host copied from the Sources, wire result types declared outside the browser-safe protocol, RPC endpoint names spelled freely on both sides, and a settings form that re-implemented the Host's Git sync checks (items M2 and M3). This change gives each of them one owner. The maintainer's rule for the batch holds: nothing users or the model see changes, and every stored configuration validates exactly as before, the timeout bounds included ([plan item 4](../../plans/review-gaps-2026-10.md#4-one-timeout-range-for-mnemon-calls) aligns those later). + +Tested revision: branch `claude/contracts-one-owner`, written on main `03dd40a4` (#423) and merged with main `7a7e1ae1` (#424, #425), 2026-10-17 to 2026-10-18 (Asia/Shanghai), Node 24.19.0 and pnpm 10.13.1, against the pinned DSH 0.2.1-rc.2; the root tests also ran against an installed DSH 0.2.0-rc.2. The checks below passed on the merge; `verify`, `verify:plugins`, the installed DSH and Git sync also passed before it. The fixes after the review were checked as the table's last row says. #425 split the sync settings section into modules that import the form's checks from `sync-view.ts` under the same names, so it needed no change. + +## What changed + +| Item | Before | Now | +|---|---|---| +| Memory Spaces' lists and embedding defaults | `src/host/protocol.ts` declared copies of `CATEGORIES`, `SOURCES`, `EDGE_TYPES`, `INTENTS` and the embedding endpoint, model, protocol and protocol list from Memory Spaces' `./contracts`. The named tools built their `category`, `source`, `edge` and `type` enums from the copy, and `config-values.ts` re-exported the embedding values from it. Since #421 no client code reads them | protocol.ts no longer declares them. `src/host/tools.ts` imports the three lists from `dsh-mnemon-source-memory-spaces/contracts`, and `config-values.ts` re-exports the embedding defaults from there, as the Host takes a space id's rule and the Sources' file names since #416. The client needs none of them, so nothing joins the client build's bundled list, and the Starter's client still takes Memory Spaces' code only through its one gateway module (`tests/plugin-repository-boundary.spec.ts`). Memory Spaces' Source also builds its category, source, intent and edge checks from those lists | +| Runtime limits | `config-values.ts` restated 10 KiB, 4 KiB and 1 MiB | The defaults come from `RUNTIME_MEMORY_LIMITS`, and the ceiling from `RUNTIME_MEMORY_LIMIT_MAX_BYTES`, a new export of `dsh-mnemon-source-runtime/contracts` with the value its configuration and controller already apply | +| Placement capabilities | Listed three times: in the Host's settings schema (`src/host/config.ts`), in Memory Spaces' `MemoryPlacementCapability` type and in its configuration's check. The labels Memory Spaces' placement gives each, a record keyed by the type, are kept complete by the compiler and stay | Memory Spaces' `./contracts` exports `MEMORY_PLACEMENT_CAPABILITIES` and derives the type from it; both schemas take the list | +| Timeout bounds | The Host takes 100–120,000 ms for `timeoutMs`, Memory Spaces 1–600,000 ms | Unchanged. A comment at each names the other range and that aligning them is on the roadmap | +| The Starter patch's defaults | `cordis.patch.yml` restates twelve settings of the `mnemon` Entry, embedding, recall quality and the timeout among them | Unchanged, with a test that every value it writes is the code's default: the schema's, which DSH's generated form shows, and `resolveConfig`'s | +| Wire result types | `src/host/subagent.ts` declared three results that cross the RPC boundary: `DelegatedAnswerResult` (what `agent-search` adds to the recalled results), `CoordinatedDocumentResult` (Document writes and archives) and `CoordinatedRuntimeMemoryResult` (runtime memory writes, as the value of a management result) | In protocol.ts; subagent.ts re-exports them, as it has re-exported `DelegatedWriteResult` since #411 | +| RPC endpoint names | Free strings: the client's `MnemonClient`, its settings scope and the operations a page lends components on one side, the Host's switches on the other | `MNEMON_RPC_ENDPOINTS` in protocol.ts lists every endpoint the Host serves on each of its nine channels, 75 in all, under the same names. `callMnemonRpc`, `MnemonClient`'s calls and the operations a page lends a component are typed against it, a component operation by its mode. A misspelled name no longer compiles, nor does a name sent on a channel or in a mode that does not serve it where the call names that channel or mode itself; a call that picks one at run time, as `assistSource` and the page's lent operations do, takes any name one of its channels serves. The View handler reads its names from the list instead of its own literals, and the API Gateway's settings guard keeps what a remote page needs for each settings endpoint in a map typed against it, so a settings endpoint added later does not compile until it is given one; it looks up only a string, so a malformed name gets the unknown-endpoint answer as before | +| Git sync checks | `src/client/sync-view.ts` re-implemented, "as the Host checks it", the Host's checks of a repository address, a branch, a directory and an author address (`sync-config.ts`), of a machine name (`machine-identity.ts`) and of a GitHub repository name (`github-auth.ts`) | `src/host/sync-rules.ts`, which imports nothing and which protocol.ts re-exports, holds the rules and `MNEMON_MACHINE_LABEL_MAX`. Both sides call them; every message stays where it was, word for word | +| Interfaces reference (a documentation error) | The channel table said remote pages may read `/dsh-mnemon-review` and need the trusted-host grant only for `reconcile` and `apply` | The row says they need `remoteAccess: trusted-host`, as `MnemonRemoteService.review` and the paragraph below the table do; a test pins the refusal of each review endpoint | + +### Where the form and the Host differ + +The shared rules keep each side's verdicts. Where the two disagreed, each still does what it did: + +- **A local repository path.** The form cannot know which platform the Host runs on, so it takes `portableAbsolutePath`: a POSIX path, a Windows path on a drive, or a UNC share. The Host passes Node's `path.isAbsolute` for its own platform, so the same paths validate on each platform as before. They differ in three cases, which `tests/sync-rules.spec.ts` pins: on a POSIX Host the form takes `C:\repo` and `\\server\share`, which the Host refuses on save, and on a Windows Host the Host takes a path from the root of the current drive, `\repo`, for which the form shows its format hint. +- **A NUL byte** in a repository address or in the directory inside the branch: only the Host refuses it, as before. +- **The rest were one rule already.** The branch checks only the Host spelled out (no `\`, no leading `/`, not `@`) follow from the pattern both use, and an empty author address keeps meaning this machine's Git identity on the Host, which the form offers as a choice of its own. + +Changing either difference changes what users see or what validates, so both are findings for the roadmap, not part of this batch. + +### Left as it is + +- **Endpoints only other callers use.** The Host serves 28 endpoints the shipped client no longer calls: the direct paths the workbench used before its pages moved to Source management and Host assistance (859d8df8, September 2026), the single-turn `turn-activity` read that the turn bar's `turn-activities` replaced in August, the activation channel's `body` endpoint, and the View channels' `preview`, `inspect-plugin` and `install-plugin`. The [Interfaces reference](../../en/reference/interfaces.md#rpc-channels) still lists them, and `scripts/verify-sync-git.mjs` writes through the direct `runtime-memory` endpoint. The endpoint test names all 28, so that removing one, or the client calling one again, is a decision; removing them changes the Host's interface and is not part of this batch. +- **The Host's defaults for what it forwards.** `timeoutMs` (10,000 ms) and `defaultRecallLimit` (10) are the Host's own settings: the first also bounds the Core's Source calls, the second the `/mnemon recall` command. Memory Spaces' equal defaults apply only to an instance the Starter does not configure, such as one mounted on its own. The patch test checks the patch against the Host's. +- **The named tools' other literal enums** (recall modes, runtime actions, targets and importance, Document actions) restate Source operation shapes that the Sources do not export as lists. What the tool schemas tell the model is audit item M4 and plan item 6. +- **`RecallResult`** stays in subagent.ts: it is what `mnemon_recall` and `mnemon_related` return to the model and what `/mnemon recall` prints, not an RPC answer. +- **Memory Spaces' page client** declares the shape of its `supervise` answer itself: a plugin can import only the public `dsh-mnemon` entries, and the protocol is not one of them. + +## Equivalence + +- **Browser bundle.** `lib/client.js` built from main `7a7e1ae1` and from this branch in the same way (the root, every plugin, the root again) differs in two places only: the region of `src/host/sync-rules.ts`, ten patterns and seven functions with their doc comments, which the form's checks in `sync-view.ts` now call, shrinking to one line each; and the operations a page lends a component, which take the endpoint they found (`names.find`) where they checked `names.includes` (948,312 to 950,182 bytes, +1,870). The endpoint list, the moved types and the removed constants never reach the client bundle: rolldown leaves out what the client does not read. Memory Spaces' and Runtime's `lib/client.js` and Memory Spaces' `lib/provider-sdk.js` are byte-identical. +- **Host bundle.** `lib/index.js` differs where the items say (686,227 to 689,453 bytes): the imports of the Memory Spaces and Runtime values with the copies gone, the runtime limit constants reading `RUNTIME_MEMORY_LIMITS` and `RUNTIME_MEMORY_LIMIT_MAX_BYTES`, the shared sync rules and the calls to them, the endpoint list with the View handler that reads it, and the remote settings map. The named tools' code is byte-identical, since the names they use are now imported under the same spelling. +- **Memory Spaces and Runtime.** Memory Spaces' `lib/contracts.js` adds `MEMORY_PLACEMENT_CAPABILITIES` (5,871 to 6,136 bytes), and its `lib/index.js` builds its checks from the contract lists (202,293 to 202,009). Runtime's contracts chunk adds `RUNTIME_MEMORY_LIMIT_MAX_BYTES` (4,018 to 4,203), which renames the chunk. +- **What the model sees.** `tests/memory-tools.spec.ts` compares every tool's name, description, input and output schema with `tests/fixtures/model-tools.json`, which this change leaves untouched; it passes. +- **Package.** `verify:package` counts 66 files instead of 65 (the new `sync-rules.d.ts`), 479,524 packed bytes instead of 476,306 and 2,030,924 unpacked instead of 2,021,100, and 35 public type dependencies (declaration files) instead of 34, the new one `sync-rules.d.ts`; the client's 21 declarations are unchanged. +- **The sync checks, verdict for verdict.** [`harness/sync-rules-equivalence.mts`](./harness/sync-rules-equivalence.mts) runs the form's six checks and the Host's six, a GitHub repository creation included, over 4,650 inputs: every string of up to three characters from 16 characters the rules treat specially (NUL, `\`, `/`, `:`, `@`, `{`, a space, a line break, U+2028 and others), values at each length limit, and repository addresses from 17 prefixes and 12 bodies. It records each verdict, each value the Host returns and each message it throws. With main's `sync-view.ts`, `sync-config.ts`, `machine-identity.ts`, `github-auth.ts` and `protocol.ts` swapped in, the output is the same file (`2f36fd57d8669c36a9374b248d7f7d83f591232dce1e0923f8e1e68df5fb0460`). With the form's path rule changed to take a single leading backslash, the form's digest changes. +- **The new tests describe what main already did.** With main's modules swapped in, the new and touched tests pass: the endpoint, API Gateway, View, client transport and component host tests on main's `src/client/api.ts`, `component-host.ts` and `remote-rpc.ts` and `src/host/view-rpc.ts` and `remote-rpc.ts` (57 tests in 5 files); the configuration and tool tests on main's `config.ts`, `config-values.ts`, `protocol.ts` and `tools.ts` (49 in 3); the sync tests on main's four sync modules (87 in 5). +- **The tests catch a drift.** An endpoint added to the list that the Host does not serve fails both endpoint tests, on the View channels too, where such a name passes the guard and meets `Unknown View operation`; `turn-activity` left out of the list fails both, the Host test because it also probes every word the handler modules spell; the patch's `timeoutMs` changed to 9,999 fails the patch test; a misspelled client endpoint, `status` sent on the Pack channel, a lent write operation named after a read endpoint, or a read component operation that names `embedding-fill` fails the type check. + +New and extended tests: + +| Where | What it shows | +|---|---| +| `tests/rpc-endpoints.spec.ts` | The Host registers the nine listed channels, and each handler serves exactly its listed endpoints, probed with every listed name and every word its module spells. The client, from each method of `MnemonClient` (the test fails when a method is added without it), from its settings scope and from the operations a page lends components, calls only listed endpoints; the listed endpoints it never calls are exactly the 28 kept for other callers | +| `tests/sync-rules.spec.ts` | The form and the Host take the same branches, directories, author addresses, machine names and GitHub repository names (the last through GitHub sign-in's own `create`), and the same repository addresses apart from local paths, where it pins the form's rule beside Node's POSIX and Windows rules; only the Host refuses a NUL byte, with its own messages | +| `tests/client-platform-boundary.spec.ts` | protocol.ts and the five modules it takes values from import nothing from Node; before, the test read protocol.ts alone | +| `tests/config.spec.ts` | The patch writes only code defaults into the `mnemon` Entry; the Host defaults and bounds the runtime limits as the Runtime Source does, at 10,240, 4,096 and 1,048,576 bytes; both schemas take exactly the listed placement capabilities; each with the schema's message | +| `tests/remote-rpc.spec.ts` | The API Gateway reaches exactly the listed settings endpoints, `mutate` only with the trusted-host grant, and refuses any other name, `constructor` and `__proto__` among them, with its message; a name that is not a string, such as `["get"]`, gets that answer without the settings handler being asked; every review endpoint needs the grant | +| Runtime `tests/contracts.spec.ts` | `RUNTIME_MEMORY_LIMITS` and `RUNTIME_MEMORY_LIMIT_MAX_BYTES` are what its configuration and controller apply, with the controller's message | +| Memory Spaces `tests/config.spec.ts`, `tests/provider-placement.spec.ts` | The persistence strategy takes exactly `MEMORY_PLACEMENT_CAPABILITIES`, refusing others with its message, and placement can check a Provider for each | + +## Compatibility + +- **Version skew.** The Starter's Host now imports two new exports, `MEMORY_PLACEMENT_CAPABILITIES` from Memory Spaces and `RUNTIME_MEMORY_LIMIT_MAX_BYTES` from Runtime, so both Sources are released with this Starter and the release PR keeps the exact pins. As with #416, Node resolves the Starter's imports from its own dependencies, so a Profile that keeps another copy of a Source changes which Source runs, not what the Starter imports. The other values it now imports exist in the pinned versions. An older Starter beside these Sources keeps its own copies, of the same values. A Starter run beside a Memory Spaces or Runtime without these exports would fail to load, which the exact pins rule out, as for #416's imports. No Source uses anything new in the Starter, so no peer floor moves. The wire is unchanged, so a page still open from the previous version talks to this Host as before. +- **Public SDK.** Additions only: the two exports above, which the two packages' READMEs now name, and `MemoryPlacementCapability` keeps its members, now derived from the list. The constants protocol.ts stops declaring were never a package export (`protocol.ts` is internal, and `dsh-mnemon/client` re-exports none of them), and the moved types keep their names in subagent.ts. +- **Persisted data and settings.** Unchanged: the schemas' bounds and defaults have the same values (the patch test and the existing configuration tests), the sync settings file validates the same values (the harness, run on POSIX; on Windows both versions call the same `path.isAbsolute`), and nothing is written differently. +- **UI and the model.** Unchanged: the browser bundle differs only as described, the form's checks give the same verdicts and the Host the same messages (the harness), and the tools match the fixture. +- **Node 20.** `sync-rules.ts` uses only the regular expressions and string methods the form already used. + +## Commands and results + +```sh +pnpm install --frozen-lockfile +pnpm run verify +MNEMON_CLI_PATH=/nonexistent/mnemon MNEMON_PLUGIN_VERIFY_CONCURRENCY=1 pnpm run verify:plugins +DSH_HOST_ROOT=/lib/node_modules/@deepseek-ai/dsh pnpm run test:installed-dsh # DSH 0.2.0-rc.2 +CHANGESET_BASE_REVISION=$(git rev-parse origin/main) CHANGESET_REVISION=$(git rev-parse HEAD) pnpm run release:intent +node scripts/verify-sync-git.mjs +MNEMON_E2E_DSH=/lib/node_modules/@deepseek-ai/dsh/lib/bin.js node scripts/verify-sync-git.mjs # DSH 0.2.0-rc.2 + +# The bundle comparison: build main and this branch the same way, then compare. +pnpm run build && pnpm --workspace-concurrency=4 -r build && pnpm run build && pnpm run verify:package +diff
/lib/client.js lib/client.js +diff
/lib/index.js lib/index.js + +# The sync checks, once on this branch and once with main's modules swapped in. +node --experimental-transform-types --no-warnings docs/pr-assets/contracts-one-owner/harness/sync-rules-equivalence.mts "$PWD" "$TMPDIR/branch.json" +for file in src/client/sync-view.ts src/host/sync-config.ts src/host/machine-identity.ts src/host/github-auth.ts src/host/protocol.ts; do git show origin/main:$file > $file; done +node --experimental-transform-types --no-warnings docs/pr-assets/contracts-one-owner/harness/sync-rules-equivalence.mts "$PWD" "$TMPDIR/main.json" +git checkout -- src +cmp "$TMPDIR/branch.json" "$TMPDIR/main.json" +``` + +| Check | Result | +|---|---| +| Lockfile | No manifest or lockfile change; `pnpm install --frozen-lockfile` | +| `pnpm run verify` | Passed: documentation links and anchors (4,816 local links, 368 anchors), type check, deterministic output for 56 files, every plugin's type check and tests (593 passed, 3 skipped in 17 packages; Runtime 81, Memory Spaces 292 and 2 skipped), root tests (2,108 passed, 7 skipped, 151 files), real Headless activation (38 tools, 8 of them Mnemon's) with the legacy settings import and the disabled Starter, package contents (66 files), 15 Node entries imported with 35 public type dependencies, publint and attw | +| Test counts | Root: 11 new (2,098 on `7a7e1ae1`), 6 in the two new files, 3 in `tests/config.spec.ts` and 2 in `tests/remote-rpc.spec.ts`, and two extended. Runtime: 1 new (80 to 81). Memory Spaces: 1 new (291 to 292) and one extended | +| `verify:plugins` | Passed: 17 standalone plugin repositories (install, type check, tests and build each), 18 packed artifacts, the external consumer (build, type check, tests), real DSH installing only the packed Starter and activating its plugins, and the three optional Strategy plugins activated together | +| Root tests on installed DSH 0.2.0-rc.2 | 2,105 passed, 10 skipped (151 files): the 3 more skips are the DSH 0.2.1-only real-host probes | +| Git sync end to end | 64 of 64 checks passed on DSH 0.2.1-rc.2 and on 0.2.0-rc.2: two real DSH Web instances configure a local bare repository through the shared checks, publish to it and pull from it | +| Sync checks harness | 4,650 inputs through 12 checks; the same output on main's modules and this branch's | +| New tests on main's modules | 57 tests in 5 files (endpoints), 49 in 3 (configuration and tools), 87 in 5 (sync) | +| Bundles | `lib/client.js` +1,870 bytes, only the shared sync rules and the lent operations' lookup; Memory Spaces' and Runtime's client bundles byte-identical | +| `release:intent` | Changeset coverage verified for `dsh-mnemon`, `dsh-mnemon-source-memory-spaces` and `dsh-mnemon-source-runtime` | +| After the review: `1928b1ab`, `b2cbe87d`, `36fd588a` and this record | Root build and `pnpm run typecheck`; the affected specs, `tests/remote-rpc.spec.ts` among them, whose new test fails without the string check and passes with main's `remote-rpc.ts`, with the endpoint, configuration, sync rule and two boundary specs (78 tests in 7 files); `pnpm run test:root` (2,109 passed, 7 skipped, 151 files); `node scripts/verify-docs.mjs` (4,816 local links, 368 anchors); `release:intent` for the same three packages | diff --git a/docs/pr-assets/contracts-one-owner/README.zh-CN.md b/docs/pr-assets/contracts-one-owner/README.zh-CN.md new file mode 100644 index 00000000..441ae9b7 --- /dev/null +++ b/docs/pr-assets/contracts-one-owner/README.zh-CN.md @@ -0,0 +1,108 @@ +# 共享的值、通信类型与同步检查各归一处 + +[English](./README.md) + +v0.5.31 之后的技术债审计发现:Starter 的 Host 复制了 Source 的值,几个通信结果类型声明在可在浏览器使用的协议之外,RPC 端点名称在两端都是随手写的字符串,设置表单又把 Host 的 Git 同步检查重新实现了一遍(M2 与 M3 两项)。本改动让它们各有唯一的归属。这一批改动的维护者规则仍然适用:用户和模型看到的内容一律不变,已保存的每项配置都和以前一样通过校验,超时范围也不例外([计划第 4 项](../../plans/review-gaps-2026-10.md#4-one-timeout-range-for-mnemon-calls)日后再统一它们)。 + +测试版本:分支 `claude/contracts-one-owner`,在 main `03dd40a4`(#423)上编写,随后合入 main `7a7e1ae1`(#424、#425),2026-10-17 至 2026-10-18(Asia/Shanghai),Node 24.19.0、pnpm 10.13.1,锁定的 DSH 0.2.1-rc.2;根测试另在安装的 DSH 0.2.0-rc.2 上运行。下列检查均在合入后的版本上通过;`verify`、`verify:plugins`、安装的 DSH 与 Git 同步在合入前也已通过。评审之后的修正按表格最后一行所列方式检查。#425 把同步设置区块拆成几个模块,它们以相同名称从 `sync-view.ts` 导入表单的检查,因此无需改动。 + +## 改动内容 + +| 项目 | 之前 | 现在 | +|---|---|---| +| 记忆空间的列表与嵌入默认值 | `src/host/protocol.ts` 声明了记忆空间 `./contracts` 中 `CATEGORIES`、`SOURCES`、`EDGE_TYPES`、`INTENTS` 以及嵌入端点、模型、协议与协议列表的副本。命名工具用这份副本生成 `category`、`source`、`edge` 与 `type` 的枚举,`config-values.ts` 也从这里再导出嵌入默认值。自 #421 起,客户端代码不再读取它们 | protocol.ts 不再声明它们。`src/host/tools.ts` 从 `dsh-mnemon-source-memory-spaces/contracts` 导入这三个列表,`config-values.ts` 从那里再导出嵌入默认值,与 #416 以来 Host 取用空间 id 规则和各 Source 文件名的方式相同。客户端一个都不需要,因此客户端构建的打包列表没有新增内容,Starter 的客户端仍只经由唯一的网关模块取用记忆空间的代码(`tests/plugin-repository-boundary.spec.ts`)。记忆空间的 Source 也改用这些列表检查分类、来源、意图与边 | +| 运行时记忆的上限 | `config-values.ts` 重写了 10 KiB、4 KiB 与 1 MiB | 默认值取自 `RUNTIME_MEMORY_LIMITS`,最大值取自 `dsh-mnemon-source-runtime/contracts` 新增的导出 `RUNTIME_MEMORY_LIMIT_MAX_BYTES`,即其配置与控制器本来就采用的值 | +| 放置能力 | 列了三遍:Host 的设置 schema(`src/host/config.ts`)、记忆空间的 `MemoryPlacementCapability` 类型及其配置中的检查。记忆空间的放置为每项能力给出的标签是以该类型为键的记录,由编译器保证完整,保持不变 | 记忆空间 `./contracts` 导出 `MEMORY_PLACEMENT_CAPABILITIES`,类型由它推导;两个 schema 都使用这个列表 | +| 超时范围 | Host 接受 100–120,000 ms 的 `timeoutMs`,记忆空间接受 1–600,000 ms | 不变。两处各加一条注释,写明另一处的范围,并说明统一它们已列入路线图 | +| Starter patch 中的默认值 | `cordis.patch.yml` 为 `mnemon` Entry 重写了十二项设置,其中包括嵌入、召回质量与超时 | 不变,另加测试保证它写入的每个值都是代码中的默认值:既是 schema 的默认值(DSH 生成的表单显示的值),也是 `resolveConfig` 的默认值 | +| 通信结果类型 | `src/host/subagent.ts` 声明了三个会经过 RPC 的结果:`DelegatedAnswerResult`(`agent-search` 附加到召回结果上的回答)、`CoordinatedDocumentResult`(文档的写入与归档)和 `CoordinatedRuntimeMemoryResult`(运行时记忆的写入,作为管理结果的值) | 移入 protocol.ts;subagent.ts 再导出它们,与它自 #411 起再导出 `DelegatedWriteResult` 的做法相同 | +| RPC 端点名称 | 随手写的字符串:一端是客户端的 `MnemonClient`、设置作用域以及页面借给组件的操作,另一端是 Host 的分支判断 | protocol.ts 中的 `MNEMON_RPC_ENDPOINTS` 列出 Host 在九个通道上提供的全部端点,共 75 个,名称不变。`callMnemonRpc`、`MnemonClient` 的调用以及页面借给组件的操作都以它为类型,组件操作还按读写方式区分。拼错的名称无法通过编译;调用本身写明通道或读写方式时,发到不提供它的通道或以不对应的方式调用的名称也无法通过编译;在运行时才选定通道或方式的调用,如 `assistSource` 与页面借出的操作,则接受其任一通道提供的名称。View 处理器改从这份列表读取名称,不再自写字面量;API Gateway 的设置检查把远程页面访问每个设置端点所需的条件记在一张以它为类型的映射中,日后新增的设置端点在给出条件之前无法通过编译;它只查找字符串,因此格式错误的名称仍得到未知端点的回答 | +| Git 同步检查 | `src/client/sync-view.ts` 以“与 Host 的检查相同”为由,重新实现了 Host 对仓库地址、分支、目录与作者邮箱的检查(`sync-config.ts`)、对计算机名称的检查(`machine-identity.ts`)以及对 GitHub 仓库名称的检查(`github-auth.ts`) | 这些规则与 `MNEMON_MACHINE_LABEL_MAX` 移入 `src/host/sync-rules.ts`,它不导入任何模块,由 protocol.ts 再导出。两端都调用它;每条提示信息仍在原处,逐字不变 | +| 接口参考(文档错误) | 通道表写着远程页面可以读取 `/dsh-mnemon-review`,只有 `reconcile` 与 `apply` 需要 trusted-host 授权 | 这一行改为需要 `remoteAccess: trusted-host`,与 `MnemonRemoteService.review` 以及表格下方的段落一致;有测试固定每个审查端点的拒绝 | + +### 表单与 Host 的不同之处 + +共享规则保留了两端各自的判断。两端原本不一致的地方,各自仍按原来的方式处理: + +- **本地仓库路径。** 表单无法知道 Host 运行在哪个平台上,因此使用 `portableAbsolutePath`:POSIX 路径、带盘符的 Windows 路径或 UNC 共享。Host 传入 Node 按其自身平台判断的 `path.isAbsolute`,因此每个平台上通过校验的路径与以前相同。两者在三种情况下不同,`tests/sync-rules.spec.ts` 都已固定:在 POSIX Host 上,表单接受 `C:\repo` 与 `\\server\share`,Host 保存时拒绝;在 Windows Host 上,Host 接受从当前驱动器根目录开始的路径 `\repo`,表单则显示格式提示。 +- **仓库地址或分支内目录中的 NUL 字节**:只有 Host 拒绝,与以前相同。 +- **其余本来就是同一条规则。** 只有 Host 写出的分支检查(不含 `\`、不以 `/` 开头、不是 `@`)已由两端共用的模式保证;空的作者邮箱在 Host 上仍表示使用这台计算机的 Git 身份,表单则把它作为单独的选项提供。 + +改变上述任一处不同,都会改变用户看到的内容或通过校验的值,因此两者是留给路线图的发现,不属于这一批改动。 + +### 保持原样的部分 + +- **只有其他调用方使用的端点。** Host 提供的端点中有 28 个已不被随附的客户端调用:工作台的页面改用 Source 管理与 Host 协助之前使用的直接路径(859d8df8,2026 年 9 月)、8 月起由回合记忆栏的 `turn-activities` 取代的单回合读取 `turn-activity`、激活通道的 `body` 端点,以及 View 通道的 `preview`、`inspect-plugin` 与 `install-plugin`。[接口参考](../../zh-CN/reference/interfaces.md#rpc-通道)仍列出它们,`scripts/verify-sync-git.mjs` 也通过直接的 `runtime-memory` 端点写入。端点测试逐一列出这 28 个,移除其中一个或让客户端重新调用其中一个都须明确决定;移除它们会改变 Host 的接口,不属于这一批改动。 +- **Host 为转发的设置定的默认值。** `timeoutMs`(10,000 ms)与 `defaultRecallLimit`(10)是 Host 自己的设置:前者也限制 Core 对 Source 的调用,后者也限制 `/mnemon recall` 命令。记忆空间相同的默认值只用于 Starter 未配置的实例,例如单独挂载的实例。patch 测试以 Host 的默认值检查 patch。 +- **命名工具中的其他字面量枚举**(召回模式、运行时记忆的操作、目标与重要性、文档操作)重述了 Source 操作的结构,而 Source 并未把它们作为列表导出。工具 schema 告诉模型什么,属于审计第 M4 项与计划第 6 项。 +- **`RecallResult`** 仍在 subagent.ts 中:它是 `mnemon_recall` 与 `mnemon_related` 返回给模型、由 `/mnemon recall` 打印的结果,不是 RPC 的回答。 +- **记忆空间的页面客户端** 自行声明 `supervise` 回答的结构:插件只能导入 `dsh-mnemon` 的公开入口,而协议不在其中。 + +## 等价性 + +- **浏览器包。** 以相同方式(根包、全部插件、再构建根包)从 main `7a7e1ae1` 与本分支构建的 `lib/client.js` 只有两处不同:一是 `src/host/sync-rules.ts` 的区段,十个模式与七个函数及其文档注释,`sync-view.ts` 中表单的检查改为调用它们,各缩短为一行;二是页面借给组件的操作改为取用找到的端点(`names.find`),原先是检查 `names.includes`(948,312 字节变为 950,182 字节,+1,870)。端点列表、移动的类型与删除的常量都不会进入客户端包:rolldown 会舍弃客户端不读取的内容。记忆空间与运行时记忆的 `lib/client.js` 以及记忆空间的 `lib/provider-sdk.js` 逐字节不变。 +- **Host 包。** `lib/index.js` 只在各项所述之处不同(686,227 字节变为 689,453 字节):导入记忆空间与运行时记忆的值并删去副本、运行时记忆上限常量改读 `RUNTIME_MEMORY_LIMITS` 与 `RUNTIME_MEMORY_LIMIT_MAX_BYTES`、共享的同步规则及其调用、端点列表与读取它的 View 处理器,以及远程设置映射。命名工具的代码逐字节不变,因为它们使用的名称现在以相同拼写导入。 +- **记忆空间与运行时记忆。** 记忆空间的 `lib/contracts.js` 增加 `MEMORY_PLACEMENT_CAPABILITIES`(5,871 字节变为 6,136 字节),`lib/index.js` 改用 contracts 中的列表做检查(202,293 字节变为 202,009 字节)。运行时记忆的 contracts chunk 增加 `RUNTIME_MEMORY_LIMIT_MAX_BYTES`(4,018 字节变为 4,203 字节),chunk 因此改名。 +- **模型看到的内容。** `tests/memory-tools.spec.ts` 把每个工具的名称、描述、输入与输出 schema 与 `tests/fixtures/model-tools.json` 比较,本改动没有修改这个文件;测试通过。 +- **包内容。** `verify:package` 统计到 66 个文件(原为 65 个,新增 `sync-rules.d.ts`),打包 479,524 字节(原为 476,306),解包 2,030,924 字节(原为 2,021,100),公开类型依赖(声明文件)35 个(原为 34 个,新增的是 `sync-rules.d.ts`);客户端的 21 个声明不变。 +- **同步检查,逐项相同。** [`harness/sync-rules-equivalence.mts`](./harness/sync-rules-equivalence.mts) 用 4,650 个输入运行表单的六项检查与 Host 的六项检查(含一次 GitHub 仓库创建):由规则特别处理的 16 个字符(NUL、`\`、`/`、`:`、`@`、`{`、空格、换行、U+2028 等)组成的所有不超过三个字符的字符串、各长度上限处的值,以及由 17 个前缀和 12 个主体组合出的仓库地址。它记录每个判断、Host 返回的每个值和抛出的每条信息。换入 main 的 `sync-view.ts`、`sync-config.ts`、`machine-identity.ts`、`github-auth.ts` 与 `protocol.ts` 后,输出文件完全相同(`2f36fd57d8669c36a9374b248d7f7d83f591232dce1e0923f8e1e68df5fb0460`)。把表单的路径规则改为接受单个开头的反斜杠后,表单的摘要随之改变。 +- **新测试描述的正是 main 原有的行为。** 换入 main 的模块后,新增与改动的测试都通过:端点、API Gateway、View、客户端传输与组件 host 测试在 main 的 `src/client/api.ts`、`component-host.ts`、`remote-rpc.ts` 以及 `src/host/view-rpc.ts`、`remote-rpc.ts` 上通过(5 个文件 57 个测试);配置与工具测试在 main 的 `config.ts`、`config-values.ts`、`protocol.ts` 与 `tools.ts` 上通过(3 个文件 49 个);同步测试在 main 的四个同步模块上通过(5 个文件 87 个)。 +- **测试能发现偏差。** 往列表中加入一个 Host 不提供的端点,两项端点测试都会失败,View 通道也不例外:这样的名称能通过其检查,随后得到 `Unknown View operation`;把 `turn-activity` 从列表中删去,两项也都会失败,其中 Host 测试是因为它还会探测处理器模块中出现的每个单词;把 patch 的 `timeoutMs` 改为 9,999,patch 测试失败;客户端端点拼错、在 Pack 通道上发送 `status`、借出的写操作用了读端点的名称,或以读方式调用 `embedding-fill` 组件操作,类型检查都会失败。 + +新增与扩展的测试: + +| 位置 | 证明的内容 | +|---|---| +| `tests/rpc-endpoints.spec.ts` | Host 注册列出的九个通道,每个处理器恰好提供为它列出的端点,探测输入包括所有列出的名称以及其模块中出现的每个单词。客户端经由 `MnemonClient` 的每个方法(新增方法而不更新测试会使测试失败)、设置作用域以及页面借给组件的操作,只调用列出的端点;它从不调用的列出端点恰好是为其他调用方保留的 28 个 | +| `tests/sync-rules.spec.ts` | 表单与 Host 接受相同的分支、目录、作者邮箱、计算机名称与 GitHub 仓库名称(后者经由 GitHub 登录自己的 `create` 检查),除本地路径外也接受相同的仓库地址;对本地路径,它把表单的规则与 Node 的 POSIX、Windows 规则并列固定;只有 Host 拒绝 NUL 字节,并给出它自己的信息 | +| `tests/client-platform-boundary.spec.ts` | protocol.ts 以及它取用值的五个模块都不导入 Node 的模块;此前测试只读取 protocol.ts 本身 | +| `tests/config.spec.ts` | patch 只把代码中的默认值写入 `mnemon` Entry;Host 对运行时记忆上限的默认值与范围和运行时记忆 Source 相同,即 10,240、4,096 与 1,048,576 字节;两个 schema 恰好接受列出的放置能力;均固定 schema 的提示信息 | +| `tests/remote-rpc.spec.ts` | API Gateway 恰好能访问列出的设置端点,`mutate` 只在有 trusted-host 授权时可用,其他名称(包括 `constructor` 与 `__proto__`)都以原有信息拒绝;不是字符串的名称(如 `["get"]`)得到同样的回答,设置处理器不会被调用;每个审查端点都需要授权 | +| 运行时记忆 `tests/contracts.spec.ts` | `RUNTIME_MEMORY_LIMITS` 与 `RUNTIME_MEMORY_LIMIT_MAX_BYTES` 就是其配置与控制器采用的值,控制器的提示信息不变 | +| 记忆空间 `tests/config.spec.ts`、`tests/provider-placement.spec.ts` | 持久化策略恰好接受 `MEMORY_PLACEMENT_CAPABILITIES`,其他值以原有提示信息拒绝;放置可以按其中每一项检查 Provider | + +## 兼容性 + +- **版本差异。** Starter 的 Host 现在导入两个新导出:记忆空间的 `MEMORY_PLACEMENT_CAPABILITIES` 与运行时记忆的 `RUNTIME_MEMORY_LIMIT_MAX_BYTES`,因此这两个 Source 须与本版 Starter 一同发布,发布 PR 保持精确锁定。与 #416 相同,Node 从 Starter 自己的依赖解析它的导入,因此保留另一份 Source 的 profile 改变的是运行哪个 Source,而不是 Starter 导入什么。它新导入的其他值在锁定的版本中都已存在。较旧的 Starter 与这些 Source 一起使用时保留自己的副本,值也相同。若 Starter 与缺少这些导出的记忆空间或运行时记忆一起运行,将无法加载;精确锁定排除了这种情况,与 #416 的导入相同。没有 Source 使用 Starter 中的新内容,peer 下限不变。通信协议不变,因此仍打开着的旧版本页面与本版 Host 的交互一如既往。 +- **公开 SDK。** 只有新增:上述两个导出,两个包的 README 现在都写明了它们;`MemoryPlacementCapability` 的成员不变,现由列表推导。protocol.ts 不再声明的常量从来不是任何包的导出(`protocol.ts` 是内部文件,`dsh-mnemon/client` 也没有再导出它们),移动的类型在 subagent.ts 中仍可按原名取用。 +- **持久化数据与设置。** 不变:schema 的范围与默认值相同(patch 测试与现有的配置测试),同步设置文件接受的值相同(验证脚本在 POSIX 上运行;在 Windows 上,两个版本调用的是同一个 `path.isAbsolute`),写入的内容也没有变化。 +- **界面与模型。** 不变:浏览器包只有上述差别,表单的检查给出相同的判断、Host 给出相同的信息(见验证脚本),工具与 fixture 一致。 +- **Node 20。** `sync-rules.ts` 只使用表单原本就用的正则表达式与字符串方法。 + +## 命令与结果 + +```sh +pnpm install --frozen-lockfile +pnpm run verify +MNEMON_CLI_PATH=/nonexistent/mnemon MNEMON_PLUGIN_VERIFY_CONCURRENCY=1 pnpm run verify:plugins +DSH_HOST_ROOT=/lib/node_modules/@deepseek-ai/dsh pnpm run test:installed-dsh # DSH 0.2.0-rc.2 +CHANGESET_BASE_REVISION=$(git rev-parse origin/main) CHANGESET_REVISION=$(git rev-parse HEAD) pnpm run release:intent +node scripts/verify-sync-git.mjs +MNEMON_E2E_DSH=/lib/node_modules/@deepseek-ai/dsh/lib/bin.js node scripts/verify-sync-git.mjs # DSH 0.2.0-rc.2 + +# 包对比:以相同方式构建 main 与本分支,再比较。 +pnpm run build && pnpm --workspace-concurrency=4 -r build && pnpm run build && pnpm run verify:package +diff
/lib/client.js lib/client.js +diff
/lib/index.js lib/index.js + +# 同步检查:在本分支上运行一次,换入 main 的模块后再运行一次。 +node --experimental-transform-types --no-warnings docs/pr-assets/contracts-one-owner/harness/sync-rules-equivalence.mts "$PWD" "$TMPDIR/branch.json" +for file in src/client/sync-view.ts src/host/sync-config.ts src/host/machine-identity.ts src/host/github-auth.ts src/host/protocol.ts; do git show origin/main:$file > $file; done +node --experimental-transform-types --no-warnings docs/pr-assets/contracts-one-owner/harness/sync-rules-equivalence.mts "$PWD" "$TMPDIR/main.json" +git checkout -- src +cmp "$TMPDIR/branch.json" "$TMPDIR/main.json" +``` + +| 检查 | 结果 | +|---|---| +| lockfile | 清单与 lockfile 均无改动;`pnpm install --frozen-lockfile` | +| `pnpm run verify` | 通过:文档链接与锚点(4,816 个本地链接、368 个锚点)、类型检查、56 个文件的确定性输出、每个插件的类型检查与测试(17 个包共 593 个通过、3 个跳过;运行时记忆 81 个,记忆空间 292 个、跳过 2 个)、根测试(151 个文件,2,108 个通过、7 个跳过)、真实 Headless 激活(共 38 个工具,其中 8 个属于 Mnemon),含旧设置导入与停用的 Starter、包内容(66 个文件)、导入 15 个 Node 入口并检查 35 个公开类型依赖、publint 与 attw | +| 测试数量 | 根测试:新增 11 个(`7a7e1ae1` 上为 2,098 个),其中 6 个在两个新文件中、3 个在 `tests/config.spec.ts` 中、2 个在 `tests/remote-rpc.spec.ts` 中,另扩展 2 个。运行时记忆:新增 1 个(80 个变为 81 个)。记忆空间:新增 1 个(291 个变为 292 个),扩展 1 个 | +| `verify:plugins` | 通过:17 个独立插件仓库(各自安装、类型检查、测试与构建)、18 个打包产物、外部使用方(构建、类型检查、测试)、真实 DSH 只安装打包的 Starter 并激活其插件,以及三个可选策略插件同时激活 | +| 安装的 DSH 0.2.0-rc.2 上的根测试 | 151 个文件,2,105 个通过、10 个跳过:多出的 3 个跳过是只适用于 DSH 0.2.1 的真实宿主探针 | +| Git 同步端到端 | 在 DSH 0.2.1-rc.2 与 0.2.0-rc.2 上 64 项检查全部通过:两个真实的 DSH Web 实例经由共享的检查配置一个本地裸仓库,向它发布并从它拉取 | +| 同步检查验证脚本 | 4,650 个输入、12 项检查;main 的模块与本分支输出相同 | +| 新测试放在 main 的模块上 | 端点 5 个文件 57 个测试,配置与工具 3 个文件 49 个,同步 5 个文件 87 个 | +| 包 | `lib/client.js` 增加 1,870 字节,只有共享的同步规则与借出操作的查找;记忆空间与运行时记忆的客户端包逐字节不变 | +| `release:intent` | 已确认 `dsh-mnemon`、`dsh-mnemon-source-memory-spaces` 与 `dsh-mnemon-source-runtime` 的 changeset 覆盖 | +| 评审之后:`1928b1ab`、`b2cbe87d`、`36fd588a` 与本记录 | 根包构建与 `pnpm run typecheck`;受影响的测试,其中 `tests/remote-rpc.spec.ts` 的新测试在去掉字符串检查时失败、换入 main 的 `remote-rpc.ts` 时通过,另有端点、配置、同步规则与两个边界测试(7 个文件 78 个测试);`pnpm run test:root`(151 个文件,2,109 个通过、7 个跳过);`node scripts/verify-docs.mjs`(4,816 个本地链接、368 个锚点);同样三个包的 `release:intent` | diff --git a/docs/pr-assets/contracts-one-owner/harness/sync-rules-equivalence.mts b/docs/pr-assets/contracts-one-owner/harness/sync-rules-equivalence.mts new file mode 100644 index 00000000..b7cd4ba9 --- /dev/null +++ b/docs/pr-assets/contracts-one-owner/harness/sync-rules-equivalence.mts @@ -0,0 +1,82 @@ +// sync-rules-equivalence.mts +// +// Runs both sides' checks of the Git sync settings over one corpus: the settings form's +// (src/client/sync-view.ts) and the Host's (src/host/sync-config.ts, the machine name in +// machine-identity.ts and the repository name GitHub sign-in creates in github-auth.ts). The corpus +// is every string of up to three characters from an alphabet of the characters the rules treat +// specially, the values at each length limit, and repository addresses built from every prefix and +// body below. Each check's verdict, the value the Host returns or the message it throws, is written +// to as a SHA-256 digest per check, so two revisions compare by diffing two files. +// Run with Node 24: node --experimental-transform-types --no-warnings +import { createHash } from 'node:crypto' +import { writeFileSync } from 'node:fs' + +const [repository, output] = process.argv.slice(2) +if (repository === undefined || output === undefined) throw new Error('usage: sync-rules-equivalence.mts ') +const form = await import(`${repository}/src/client/sync-view.ts`) +const host = await import(`${repository}/src/host/sync-config.ts`) +const { machineLabel } = await import(`${repository}/src/host/machine-identity.ts`) +const { MnemonGitHubAuth } = await import(`${repository}/src/host/github-auth.ts`) + +const alphabet = ['a', 'Z', '0', '.', '-', '_', '/', '\\', ':', '@', '{', ' ', '\0', '\n', 'é', '\u2028'] +const short: string[] = [''] +for (let length = 1, layer = ['']; length <= 3; length++) { + layer = layer.flatMap(prefix => alphabet.map(character => prefix + character)) + short.push(...layer) +} +const limits = [99, 100, 101, 119, 120, 121, 199, 200, 201, 310, 311, 312, 319, 320, 321, 399, 400, 401, 1999, 2000, 2001].flatMap(length => [ + 'x'.repeat(length), `${'x'.repeat(Math.max(0, length - 12))}@example.com`, `https://github.com/${'x'.repeat(Math.max(0, length - 19))}`, +]) +const prefixes = ['', 'https://', 'HTTPS://', 'ssh://', 'git@', 'git+ssh://', 'file://', 'ftp://', '/', 'C:\\', 'C:/', 'c:', '\\\\', '\\', '//', '~/', '.'] +const bodies = ['github.com/example/memory.git', 'user:secret@github.com/example/memory.git', 'token@github.com/example/memory', 'github.com:22/example/memory.git', + 'github.com:example/memory.git', 'github.com', 'srv/memory.git', 'memory\\repo.git', 'server\\share\\repo.git', 'two words/repo', 'nul\0/repo', ''] +const curated = ['mnemon-sync', 'user/mnemon-sync', 'v1.2', ' padded ', 'a//b', 'a..b', 'a@{1}', 'x.lock', 'mnemon/', '/nested/memory/', 'a/../b', + 'me@example.com', ' me@example.com ', 'me@@example.com', '@example.com', 'Studio', 'two\nlines', 'tab\tbed', 'mnemon-memory', '...', 'a.b_c-d'] +const corpus = [...new Set([...short, ...limits, ...curated, ...prefixes.flatMap(prefix => bodies.map(body => prefix + body))])] + +/** What a Host check makes of a value: what it returns, or the message it throws. */ +function outcome(check: (value: unknown) => unknown, value: string): unknown { + try { + return { value: check(value) } + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} + +// GitHub sign-in checks the name before it asks GitHub; the stand-in answers any request with a repository. +const grant = { version: 1, accessToken: 'gho_equivalence', savedAt: '2026-10-17T08:00:00.000Z' } +const port = { available: () => true, describe: async () => ({ configured: true, writable: true }), read: async () => grant, write: async () => {}, clear: async () => {} } +const repository_ = { name: 'memory', full_name: 'octocat/memory', clone_url: 'https://github.com/octocat/memory.git', private: true, default_branch: 'main', owner: { login: 'octocat' }, permissions: { push: true } } +const request = (async () => new Response(JSON.stringify(repository_), { status: 201, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch +const github = new MnemonGitHubAuth(port, 'Ov23liq4i7n8UsylGRfb', () => Date.parse(grant.savedAt), request) +async function githubOutcome(name: string): Promise { + try { + await github.create(name, true) + return 'accepted' + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} + +const checks: Record> = { + 'form repositoryProblem': corpus.map(value => form.repositoryProblem(value) ?? null), + 'form branchValid': corpus.map(value => form.branchValid(value)), + 'form directoryValid': corpus.map(value => form.directoryValid(value)), + 'form emailValid': corpus.map(value => form.emailValid(value)), + 'form machineNameValid': corpus.map(value => form.machineNameValid(value)), + 'form repositoryNameValid': corpus.map(value => form.repositoryNameValid(value)), + 'host syncRepositoryUrl': corpus.map(value => outcome(host.syncRepositoryUrl, value)), + 'host syncBranch': corpus.map(value => outcome(host.syncBranch, value)), + 'host syncSubdirectory': corpus.map(value => outcome(host.syncSubdirectory, value)), + 'host syncAuthorEmail': corpus.map(value => outcome(host.syncAuthorEmail, value)), + 'host machineLabel': corpus.map(value => outcome(machineLabel, value)), + 'host GitHub create': await Promise.all(corpus.map(githubOutcome)), +} +const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex') +const result = { + inputs: corpus.length, + corpus: digest(corpus), + checks: Object.fromEntries(Object.entries(checks).map(([name, values]) => [name, digest(values)])), +} +writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`) +console.log(`${corpus.length} inputs through ${Object.keys(checks).length} checks: ${output}`) diff --git a/docs/zh-CN/reference/interfaces.md b/docs/zh-CN/reference/interfaces.md index 66638678..6d1942e5 100644 --- a/docs/zh-CN/reference/interfaces.md +++ b/docs/zh-CN/reference/interfaces.md @@ -167,7 +167,7 @@ RPC 是 DSH Host 与插件客户端之间的内部桥,不是稳定外部 HTTP | `/dsh-mnemon-write` | 其余所有 mutation | 需要 `remoteAccess: trusted-host` | | `/dsh-mnemon-pack` | 备份导出与导入 | 需要 `remoteAccess: trusted-host` | | `/dsh-mnemon-sync` | Git 仓库同步:配置、推送、预览与拉取 | 需要 `remoteAccess: trusted-host` | -| `/dsh-mnemon-review` | 合并记忆的整理建议、审查意见,以及执行已接受的方案 | 读取允许;`reconcile` 与 `apply` 需要 `remoteAccess: trusted-host` | +| `/dsh-mnemon-review` | 合并记忆的整理建议、审查意见,以及执行已接受的方案 | 需要 `remoteAccess: trusted-host` | | `/dsh-mnemon-settings` | Host 与界面设置 | `get` 允许;`mutate` 需要 `remoteAccess: trusted-host` | | `/dsh-mnemon-view` | 记忆组合读取 | 允许 | | `/dsh-mnemon-view-settings` | 保存记忆组合、安装组件 | 需要 `remoteAccess: trusted-host` | diff --git a/plugins/dsh-mnemon-source-memory-spaces/README.md b/plugins/dsh-mnemon-source-memory-spaces/README.md index 4fa9fa28..09148f67 100644 --- a/plugins/dsh-mnemon-source-memory-spaces/README.md +++ b/plugins/dsh-mnemon-source-memory-spaces/README.md @@ -31,6 +31,8 @@ The `./contracts` entry names the Native layout under a storage root (`data/(MEMORY_PLACEMENT_CAPABILITIES) const MEMORY_PLACEMENT_PREFERENCE_SET = new Set(['balanced', 'local-first', 'shared-first']) export function resolvePersistenceStrategy(value: MemoryPersistenceStrategy | undefined): ResolvedMemorySpacesConfig['persistenceStrategy'] { const mode = value?.mode ?? 'manual' diff --git a/plugins/dsh-mnemon-source-memory-spaces/src/contracts.ts b/plugins/dsh-mnemon-source-memory-spaces/src/contracts.ts index 37572b59..6fc41551 100644 --- a/plugins/dsh-mnemon-source-memory-spaces/src/contracts.ts +++ b/plugins/dsh-mnemon-source-memory-spaces/src/contracts.ts @@ -62,7 +62,9 @@ export interface MemoryProviderConfigField { options?: MemoryProviderConfigOption[] } -export type MemoryPlacementCapability = 'graph' | 'entities' | 'related' | 'exact-write' | 'link' | 'forget' +/** What a persistence strategy's rules can require of a Provider; the Starter's settings schema takes the same list. */ +export const MEMORY_PLACEMENT_CAPABILITIES = ['graph', 'entities', 'related', 'exact-write', 'link', 'forget'] as const +export type MemoryPlacementCapability = (typeof MEMORY_PLACEMENT_CAPABILITIES)[number] export type MemoryPlacementPreference = 'balanced' | 'local-first' | 'shared-first' export interface MemoryPlacementRules { diff --git a/plugins/dsh-mnemon-source-memory-spaces/src/source.ts b/plugins/dsh-mnemon-source-memory-spaces/src/source.ts index 7f6c52d7..a28732fe 100644 --- a/plugins/dsh-mnemon-source-memory-spaces/src/source.ts +++ b/plugins/dsh-mnemon-source-memory-spaces/src/source.ts @@ -19,7 +19,7 @@ import { MemoryProviderCatalog } from './providers/catalog.ts' import { finalizeLlmPlacement, rulesOnlyPlacement } from './provider-placement.ts' import { resolveMemorySpacesConfig, type MemorySpacesConfig } from './config.ts' import { MemorySpaceProviderSnapshot } from './providers/host.ts' -import { isNativeProvider, MEMORY_SPACE_DESCRIPTION_MAX_LENGTH, MEMORY_SPACE_NAME_MAX_LENGTH } from './contracts.ts' +import { CATEGORIES, EDGE_TYPES, INTENTS, isNativeProvider, MEMORY_SPACE_DESCRIPTION_MAX_LENGTH, MEMORY_SPACE_NAME_MAX_LENGTH, SOURCES } from './contracts.ts' import type { Category, CreateMemorySpaceRequest, @@ -36,10 +36,10 @@ import type { UpdateMemorySpaceRequest, } from './contracts.ts' -const CATEGORIES = new Set(['preference', 'decision', 'fact', 'insight', 'context', 'general']) -const SOURCES = new Set(['user', 'agent', 'external']) -const INTENTS = new Set(['WHY', 'WHEN', 'ENTITY', 'GENERAL']) -const EDGES = new Set(['temporal', 'semantic', 'causal', 'entity']) +const CATEGORY_SET = new Set(CATEGORIES) +const SOURCE_SET = new Set(SOURCES) +const INTENT_SET = new Set(INTENTS) +const EDGE_SET = new Set(EDGE_TYPES) function grantIds(grant: MemoryReadGrant): string[] { return stringArray(record(grant.value, 'Memory Spaces ReadGrant').memoryBodyIds, 'memoryBodyIds', 10_000) ?? [] @@ -571,9 +571,9 @@ export function createMemorySpacesSource(providerSnapshot: MemorySpaceProviderSn const category = text(input.category, 'category', 30, false) as Category | undefined const source = text(input.source, 'source', 30, false) as Source | undefined const intent = text(input.intent, 'intent', 30, false) as Intent | undefined - if (category !== undefined && !CATEGORIES.has(category)) throw new Error(`unsupported category: ${category}`) - if (source !== undefined && !SOURCES.has(source)) throw new Error(`unsupported source: ${source}`) - if (intent !== undefined && !INTENTS.has(intent)) throw new Error(`unsupported intent: ${intent}`) + if (category !== undefined && !CATEGORY_SET.has(category)) throw new Error(`unsupported category: ${category}`) + if (source !== undefined && !SOURCE_SET.has(source)) throw new Error(`unsupported source: ${source}`) + if (intent !== undefined && !INTENT_SET.has(intent)) throw new Error(`unsupported intent: ${intent}`) const requestedSpaces = stringArray(input.memoryBodyIds, 'memoryBodyIds', 10_000) ?? allowedBodies if (requestedSpaces.some(id => !allowedBodies.includes(id))) throw new Error('Recall requested a Memory Space outside this View ReadGrant') const mode = text(input.mode, 'mode', 20, false) as 'smart' | 'keyword' | 'basic' | undefined @@ -593,7 +593,7 @@ export function createMemorySpacesSource(providerSnapshot: MemorySpaceProviderSn if (owner === undefined) throw new Error('related-memory traversal requires evidence already admitted by this View, or an id found in a Memory Space it can read') if (!allowedBodies.includes(owner)) throw new Error('related-memory owner is outside this View ReadGrant') const edge = text(input.edge, 'edge', 30, false) as EdgeType | undefined - if (edge !== undefined && !EDGES.has(edge)) throw new Error(`unsupported edge: ${edge}`) + if (edge !== undefined && !EDGE_SET.has(edge)) throw new Error(`unsupported edge: ${edge}`) const results = await service.related(id, integer(input.depth, 2, 1, 5), edge, request.signal, owner) return evidence(request, results) } @@ -648,8 +648,8 @@ export function createMemorySpacesSource(providerSnapshot: MemorySpaceProviderSn if (!writableBodies.includes(bodyId)) throw new Error('remember destination is outside this View scope') const category = text(input.category, 'category', 30, false) as Category | undefined const source = text(input.source, 'source', 30, false) as Source | undefined - if (category !== undefined && !CATEGORIES.has(category)) throw new Error(`unsupported category: ${category}`) - if (source !== undefined && !SOURCES.has(source)) throw new Error(`unsupported source: ${source}`) + if (category !== undefined && !CATEGORY_SET.has(category)) throw new Error(`unsupported category: ${category}`) + if (source !== undefined && !SOURCE_SET.has(source)) throw new Error(`unsupported source: ${source}`) result = await service.remember({ content: text(input.content, 'content', 100_000)!, ...(category === undefined ? {} : { category }), ...(source === undefined ? {} : { source }), @@ -668,7 +668,7 @@ export function createMemorySpacesSource(providerSnapshot: MemorySpaceProviderSn } bodyId = sourceSpace const edge = text(input.type, 'type', 30, false) as EdgeType | undefined - if (edge !== undefined && !EDGES.has(edge)) throw new Error(`unsupported edge: ${edge}`) + if (edge !== undefined && !EDGE_SET.has(edge)) throw new Error(`unsupported edge: ${edge}`) const weight = typeof input.weight === 'number' ? input.weight : 0.5 result = await service.link(sourceId, targetId, edge, weight, text(input.reason, 'reason', 1_000, false), request.signal, bodyId) as MemoryJsonValue } else if (request.offer.sourceActionId === 'forget') { diff --git a/plugins/dsh-mnemon-source-memory-spaces/tests/config.spec.ts b/plugins/dsh-mnemon-source-memory-spaces/tests/config.spec.ts index 002ba8e0..e37c0a4e 100644 --- a/plugins/dsh-mnemon-source-memory-spaces/tests/config.spec.ts +++ b/plugins/dsh-mnemon-source-memory-spaces/tests/config.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { resolveMemorySpacesConfig as resolveConfig } from '../src/config.ts' +import { MEMORY_PLACEMENT_CAPABILITIES } from '../src/contracts.ts' describe('Memory Spaces configuration rules', () => { it('validates configurable recall quality policy thresholds and expansion', () => { @@ -70,6 +71,17 @@ describe('Memory Spaces configuration rules', () => { }) }) + it('takes exactly the placement capabilities its contracts list', () => { + // The Starter's settings schema offers the same list. + expect(MEMORY_PLACEMENT_CAPABILITIES).toEqual(['graph', 'entities', 'related', 'exact-write', 'link', 'forget']) + expect(resolveConfig({ persistenceStrategy: { mode: 'automatic', rules: { requiredCapabilities: [...MEMORY_PLACEMENT_CAPABILITIES] } } }) + .persistenceStrategy.rules.requiredCapabilities).toEqual([...MEMORY_PLACEMENT_CAPABILITIES]) + for (const capability of ['telepathy', 'search', 'Graph']) { + expect(() => resolveConfig({ persistenceStrategy: { rules: { requiredCapabilities: [capability as never] } } }), capability) + .toThrow(`dsh-mnemon: unsupported persistence capability: ${capability}`) + } + }) + it('marks only an unchosen persistence provider as the default', () => { expect(resolveConfig({}).persistenceStrategy).toMatchObject({ mode: 'manual', providerId: 'mnemon-native', providerDefaulted: true }) expect(resolveConfig({ persistenceStrategy: { providerId: 'mnemon-native' } }).persistenceStrategy).not.toHaveProperty('providerDefaulted') diff --git a/plugins/dsh-mnemon-source-memory-spaces/tests/provider-placement.spec.ts b/plugins/dsh-mnemon-source-memory-spaces/tests/provider-placement.spec.ts index a964d7c1..796ff576 100644 --- a/plugins/dsh-mnemon-source-memory-spaces/tests/provider-placement.spec.ts +++ b/plugins/dsh-mnemon-source-memory-spaces/tests/provider-placement.spec.ts @@ -4,7 +4,7 @@ import { prepareMemoryPlacement, rulesOnlyPlacement, } from '../src/provider-placement.ts' -import type { MemoryPlacementCandidate, MemoryPlacementCapability } from "../src/contracts.ts" +import { MEMORY_PLACEMENT_CAPABILITIES, type MemoryPlacementCandidate, type MemoryPlacementCapability } from "../src/contracts.ts" const candidates: MemoryPlacementCandidate[] = [ { @@ -109,5 +109,9 @@ describe('memory provider placement', () => { mode: 'automatic', rules: { requiredCapabilities: ['telepathy' as MemoryPlacementCapability] }, }, candidates)).toThrow('unsupported required memory capability') + // Every capability the contracts list is one placement can check a Provider for. + for (const capability of MEMORY_PLACEMENT_CAPABILITIES) { + expect(() => prepareMemoryPlacement({ mode: 'automatic', rules: { requiredCapabilities: [capability] } }, candidates), capability).not.toThrow() + } }) }) diff --git a/plugins/dsh-mnemon-source-runtime/README.md b/plugins/dsh-mnemon-source-runtime/README.md index c02a2e95..69fdf033 100644 --- a/plugins/dsh-mnemon-source-runtime/README.md +++ b/plugins/dsh-mnemon-source-runtime/README.md @@ -25,6 +25,8 @@ Model projections annotate each entry with its recorded importance and elapsed w The `./contracts` entry names this Source's files under a storage root (`runtime/memories.json`, `USER.md`, `MEMORY.md`, the `.memories.lock` lock and the `archived/` local archive) and exports the rules a copy of them follows: `runtimeProjection` and `runtimeBytes` write and count USER.md and MEMORY.md exactly as the controller does, and `summarizeRuntimeMemories` counts a stored file for an inventory. They are pure and browser-safe. The `dsh-mnemon` Starter's backup, sync and storage inventory use them instead of keeping copies. +The same entry exports the size limits the configuration and the controller apply: `RUNTIME_MEMORY_LIMITS`, the default limit of USER.md and of MEMORY.md, and `RUNTIME_MEMORY_LIMIT_MAX_BYTES`, the largest limit either may be given. The Starter's settings take their defaults and bounds from them. + ## Source-owned UI and tests The optional `./client` entry is an ordinary DSH Client plugin. This package owns its pages and `presentation/` resources and uses `dsh-mnemon/client` for the shared frame and scoped management client. It never receives a Host Context, credentials or another Source's controller. diff --git a/plugins/dsh-mnemon-source-runtime/src/contracts.ts b/plugins/dsh-mnemon-source-runtime/src/contracts.ts index 3e6a5186..fc3e4e95 100644 --- a/plugins/dsh-mnemon-source-runtime/src/contracts.ts +++ b/plugins/dsh-mnemon-source-runtime/src/contracts.ts @@ -93,7 +93,7 @@ export interface RuntimeMemoryMaintenancePlan { localArchive?: true } -import { DEFAULT_RUNTIME_MEMORY_LIMIT_BYTES, DEFAULT_RUNTIME_USER_LIMIT_BYTES } from './defaults.ts' +import { DEFAULT_RUNTIME_MEMORY_LIMIT_BYTES, DEFAULT_RUNTIME_USER_LIMIT_BYTES, MAX_RUNTIME_MEMORY_LIMIT_BYTES } from './defaults.ts' export const RUNTIME_MEMORY_VERSION = 1 export const RUNTIME_ENTRY_DELIMITER = '\n§\n' export interface RuntimeMemoryLimits { @@ -104,6 +104,8 @@ export const RUNTIME_MEMORY_LIMITS: RuntimeMemoryLimits = { memory: DEFAULT_RUNTIME_MEMORY_LIMIT_BYTES, user: DEFAULT_RUNTIME_USER_LIMIT_BYTES, } +/** The largest limit, in UTF-8 bytes, either target's file may be given. */ +export const RUNTIME_MEMORY_LIMIT_MAX_BYTES = MAX_RUNTIME_MEMORY_LIMIT_BYTES /** The longest git branch name a scope may hold. */ export const RUNTIME_BRANCH_NAME_MAX = 128 diff --git a/plugins/dsh-mnemon-source-runtime/tests/contracts.spec.ts b/plugins/dsh-mnemon-source-runtime/tests/contracts.spec.ts index 9fe32915..85947aee 100644 --- a/plugins/dsh-mnemon-source-runtime/tests/contracts.spec.ts +++ b/plugins/dsh-mnemon-source-runtime/tests/contracts.spec.ts @@ -4,8 +4,10 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { RUNTIME_ARCHIVE_DIRECTORY, RUNTIME_DIRECTORY, RUNTIME_ENTRY_DELIMITER, RUNTIME_ENTRY_MAX_BYTES, RUNTIME_LOCK_FILE, RUNTIME_MEMORIES_FILE, - RUNTIME_PROJECTION_FILES, runtimeBytes, runtimeProjection, summarizeRuntimeMemories, type RuntimeMemoryEntry, + RUNTIME_MEMORY_LIMIT_MAX_BYTES, RUNTIME_MEMORY_LIMITS, RUNTIME_PROJECTION_FILES, runtimeBytes, runtimeProjection, summarizeRuntimeMemories, + type RuntimeMemoryEntry, } from '../src/contracts.ts' +import { runtimeSourceConfig } from '../src/config.ts' import { RuntimeMemoryController } from '../src/controller.ts' const directories: string[] = [] @@ -83,6 +85,25 @@ describe('Runtime storage contracts', () => { await expect(controller.mutate({ action: 'add', target: 'memory', content: 'y'.repeat(RUNTIME_ENTRY_MAX_BYTES + 1) })).rejects.toThrow(`max ${RUNTIME_ENTRY_MAX_BYTES}`) }) + it('state the limits the configuration and the controller apply', () => { + // The Starter's settings default and bound both files with these. + expect(RUNTIME_MEMORY_LIMITS).toEqual({ memory: 10 * 1024, user: 4 * 1024 }) + expect(RUNTIME_MEMORY_LIMIT_MAX_BYTES).toBe(1024 * 1024) + const { root, controller } = fixture() + expect(controller.limits).toEqual(RUNTIME_MEMORY_LIMITS) + expect(runtimeSourceConfig({ dataDir: root }, 'contracts')).toMatchObject({ memoryLimitBytes: RUNTIME_MEMORY_LIMITS.memory, userLimitBytes: RUNTIME_MEMORY_LIMITS.user }) + for (const key of ['memoryLimitBytes', 'userLimitBytes'] as const) { + expect(runtimeSourceConfig({ dataDir: root, [key]: RUNTIME_MEMORY_LIMIT_MAX_BYTES }, 'contracts')[key]).toBe(RUNTIME_MEMORY_LIMIT_MAX_BYTES) + expect(() => runtimeSourceConfig({ dataDir: root, [key]: RUNTIME_MEMORY_LIMIT_MAX_BYTES + 1 }, 'contracts')).toThrow() + } + for (const target of ['memory', 'user'] as const) { + const storage = { effectiveDataDir: () => root } + expect(new RuntimeMemoryController(storage, undefined, { ...RUNTIME_MEMORY_LIMITS, [target]: RUNTIME_MEMORY_LIMIT_MAX_BYTES }).limits[target]).toBe(RUNTIME_MEMORY_LIMIT_MAX_BYTES) + expect(() => new RuntimeMemoryController(storage, undefined, { ...RUNTIME_MEMORY_LIMITS, [target]: RUNTIME_MEMORY_LIMIT_MAX_BYTES + 1 })) + .toThrow(`runtime ${target} memory limit must be an integer within 1..${RUNTIME_MEMORY_LIMIT_MAX_BYTES} bytes`) + } + }) + it('summarize a stored file for an inventory without judging its entries, and never throw', async () => { const { root, controller } = fixture() await controller.mutate({ action: 'add', target: 'user', content: 'One profile fact' }) diff --git a/src/client/api.ts b/src/client/api.ts index e80d4611..8e9dffa9 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -16,6 +16,8 @@ import { type MemorySourceManagementCatalog, type MemorySourceManagementResult, type MnemonPackComponent, + type MnemonRpcChannel, + type MnemonRpcEndpoint, type MnemonPackExport, type MnemonPackImportResult, type MnemonPackPreview, @@ -46,6 +48,9 @@ import { } from "../host/protocol.ts" import { callMnemonRpc } from './remote-rpc.ts' +/** The channel a component's read or write operation goes on. */ +type ComponentChannel = Mode extends 'read' ? typeof MNEMON_READ_CHANNEL : typeof MNEMON_WRITE_CHANNEL + interface TurnActivityCacheEntry { cursor: number activities: Map @@ -88,7 +93,7 @@ async function loadTurnActivities(connection: ClientConnectionHandle, sessionId: export class MnemonClient { constructor(private readonly connection: ClientConnectionHandle, private readonly sessionId?: string, private readonly workspaceId?: string) {} - private async call(channel: string, endpoint: string, payload: unknown): Promise { + private async call(channel: Channel, endpoint: MnemonRpcEndpoint, payload: unknown): Promise { const response = await callMnemonRpc(this.connection, channel, endpoint, payload) if (!response.ok) { // A failure the Host could name keeps that name, so the page can translate it. @@ -157,7 +162,7 @@ export class MnemonClient { * endpoint name (component-host.ts): Memory Spaces reads its Providers and * embedding through these. */ - componentOperation(mode: 'read' | 'write', endpoint: string, payload: object): Promise { + componentOperation(mode: Mode, endpoint: MnemonRpcEndpoint>, payload: object): Promise { return this.call(mode === 'read' ? MNEMON_READ_CHANNEL : MNEMON_WRITE_CHANNEL, endpoint, this.scoped(payload)) } diff --git a/src/client/component-host.ts b/src/client/component-host.ts index d57f5e33..ca435465 100644 --- a/src/client/component-host.ts +++ b/src/client/component-host.ts @@ -1,5 +1,5 @@ import type { MemoryJsonValue } from 'dsh-mnemon/contracts' -import type { ClientConnectionHandle, ClientSettingsScope, ClientSettingsSnapshot, Config } from '../host/protocol.ts' +import type { ClientConnectionHandle, ClientSettingsScope, ClientSettingsSnapshot, Config, MNEMON_READ_CHANNEL, MNEMON_WRITE_CHANNEL, MnemonRpcEndpoint } from '../host/protocol.ts' import { MnemonClient } from './api.ts' import type { MnemonChangeSignal } from './change-signal.ts' import type { MemoryComponentConfigurationSnapshot, MemoryComponentHost } from './component-ui.tsx' @@ -11,8 +11,8 @@ interface Lent { /** The ones it owns and may write. */ owns: readonly string[] /** Host operations on the read and write channels, by the names the Host serves them under. */ - read: readonly string[] - write: readonly string[] + read: readonly MnemonRpcEndpoint[] + write: readonly MnemonRpcEndpoint[] /** * Reads the settings make on their own, which a later mount may join while the same one is * under way at the same configuration revision. A read the user asks for is never joined. @@ -93,12 +93,13 @@ export function createComponentHosts(services: ComponentHostServices): Component } const { connection, componentChanges } = services const client = connection === undefined ? undefined : new MnemonClient(connection, sessionId, workspaceId) - const call = (mode: 'read' | 'write', names: readonly string[], operation: string, input: MemoryJsonValue | undefined): Promise => { - if (!names.includes(operation)) return Promise.reject(new Error(`dsh-mnemon does not serve this component the ${mode} operation ${operation}`)) + const call = (mode: 'read' | 'write', names: Lent['read'] | Lent['write'], operation: string, input: MemoryJsonValue | undefined): Promise => { + const endpoint = names.find(name => name === operation) + if (endpoint === undefined) return Promise.reject(new Error(`dsh-mnemon does not serve this component the ${mode} operation ${operation}`)) if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) return Promise.reject(new Error(`${operation} takes an object`)) const named = SCOPE_FIELDS.filter(field => input !== undefined && Object.hasOwn(input, field)) if (named.length > 0) return Promise.reject(new Error(`${operation} runs in the page's conversation and workspace; it takes no ${named.join(' or ')}`)) - return client!.componentOperation(mode, operation, input ?? {}) + return client!.componentOperation(mode, endpoint, input ?? {}) } // A read the settings start while the same one is under way joins it, as when the // component's own settings replace dsh-mnemon's while those still load. Only one started diff --git a/src/client/remote-rpc.ts b/src/client/remote-rpc.ts index 8dddd9e1..bf80a726 100644 --- a/src/client/remote-rpc.ts +++ b/src/client/remote-rpc.ts @@ -19,6 +19,8 @@ import { MNEMON_VIEW_WRITE_CHANNEL, MNEMON_WRITE_CHANNEL, type ClientConnectionHandle, + type MnemonRpcChannel, + type MnemonRpcEndpoint, } from '../host/protocol.ts' type ClientRpcResult = Awaited> @@ -77,10 +79,10 @@ export function isRemoteConnection(connection: ClientConnectionHandle): boolean } /** Route only paired remote pages through API Gateway; local clients retain legacy channels. */ -export async function callMnemonRpc( +export async function callMnemonRpc( connection: ClientConnectionHandle, - channel: string, - endpoint: string, + channel: Channel, + endpoint: MnemonRpcEndpoint, payload: unknown, signal?: AbortSignal, ): Promise { diff --git a/src/client/sync-view.ts b/src/client/sync-view.ts index 41b38a32..9adf2cca 100644 --- a/src/client/sync-view.ts +++ b/src/client/sync-view.ts @@ -1,10 +1,17 @@ import { - MNEMON_MACHINE_LABEL_MAX, + githubRepositoryNameValid, + machineLabelValid, MNEMON_SYNC_DEFAULT_AUTHOR_EMAIL, MNEMON_SYNC_DEFAULT_AUTHOR_NAME, MNEMON_SYNC_TOKEN_ENV, + portableAbsolutePath, + syncBranchValid, + syncDirectoryParts, + syncEmailValid, + syncRepositoryProblem, type MnemonSyncFailureReason, type MnemonSyncRemoteIssue, + type MnemonSyncRepositoryProblem, type MnemonSyncStatus, } from '../host/protocol.ts' import type { MnemonTranslate } from './locales.ts' @@ -42,51 +49,37 @@ export function repositoryName(url: string): string { } } -/** What is wrong with a typed repository address, the same rules the Host applies. */ -export type RepositoryProblem = 'format' | 'credentials' +/** What is wrong with a typed repository address, by the Host's rules; a local path is checked as the browser can. */ +export type RepositoryProblem = MnemonSyncRepositoryProblem export function repositoryProblem(url: string): RepositoryProblem | undefined { const value = url.trim() - if (value === '') return undefined - if (value.length > 2000 || /\s/u.test(value)) return 'format' - if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(value)) { - if (!/^(https|ssh):\/\/[^\s/]+(?::\d+)?\/[^\s]+$/u.test(value)) return 'format' - return /^https:\/\/[^/]*@/u.test(value) ? 'credentials' : undefined - } - if (/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s]+$/u.test(value)) return undefined - // An absolute path on the Host: POSIX, a Windows drive, or a UNC share. - return value.startsWith('/') || /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\') ? undefined : 'format' + // An empty address clears the repository. + return value === '' ? undefined : syncRepositoryProblem(value, portableAbsolutePath) } /** A Git branch name, as the Host checks it. */ export function branchValid(value: string): boolean { - const name = value.trim() - return name !== '' && name.length <= 200 && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(name) && !name.includes('..') && !name.includes('//') - && !name.includes('@{') && !name.endsWith('/') && !name.endsWith('.') && !name.endsWith('.lock') + return syncBranchValid(value.trim()) } /** A directory inside the branch, as the Host checks it. */ export function directoryValid(value: string): boolean { - const raw = value.trim().replaceAll('\\', '/') - if (raw === '' || raw.length > 400 || /^[A-Za-z]:/u.test(raw)) return false - const parts = raw.split('/').filter(part => part !== '') - return parts.length > 0 && !parts.some(part => part === '.' || part === '..' || part.includes(':')) + return syncDirectoryParts(value) !== undefined } export function emailValid(value: string): boolean { - const email = value.trim() - return email.length <= 320 && /^[^\s<>@]+@[^\s<>@]+$/u.test(email) + return syncEmailValid(value.trim()) } /** A name for this computer, as the Host stores one: one line, at most the shared limit. */ export function machineNameValid(value: string): boolean { - const name = value.trim() - return name !== '' && name.length <= MNEMON_MACHINE_LABEL_MAX && !/[\p{Cc}\u2028\u2029]/u.test(name) + return machineLabelValid(value.trim()) } /** A GitHub repository name, as GitHub accepts one. */ export function repositoryNameValid(value: string): boolean { - return /^(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/u.test(value.trim()) + return githubRepositoryNameValid(value.trim()) } /** Who a sync commit names: the channel's identity, this computer's Git identity, or a typed one. */ diff --git a/src/host/config-values.ts b/src/host/config-values.ts index 05503b02..308d76e1 100644 --- a/src/host/config-values.ts +++ b/src/host/config-values.ts @@ -1,12 +1,14 @@ import { resolveRecallQuality } from 'dsh-mnemon-source-memory-spaces' +import { RUNTIME_MEMORY_LIMIT_MAX_BYTES, RUNTIME_MEMORY_LIMITS } from 'dsh-mnemon-source-runtime/contracts' export const DEFAULT_IDLE_REVIEW_MS = 30_000 -export const DEFAULT_RUNTIME_MEMORY_LIMIT_BYTES = 10 * 1024 -export const DEFAULT_RUNTIME_USER_LIMIT_BYTES = 4 * 1024 -export const MAX_RUNTIME_MEMORY_LIMIT_BYTES = 1024 * 1024 +// The Runtime Source's own defaults and ceiling for its two files, which these settings configure. +export const DEFAULT_RUNTIME_MEMORY_LIMIT_BYTES = RUNTIME_MEMORY_LIMITS.memory +export const DEFAULT_RUNTIME_USER_LIMIT_BYTES = RUNTIME_MEMORY_LIMITS.user +export const MAX_RUNTIME_MEMORY_LIMIT_BYTES = RUNTIME_MEMORY_LIMIT_MAX_BYTES export const DEFAULT_RUNTIME_MAINTENANCE_MAX_TOKENS = 8_192 export const MAX_RUNTIME_MAINTENANCE_MAX_TOKENS = 1_000_000 -export { DEFAULT_EMBEDDING_ENDPOINT, DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_PROTOCOL, MNEMON_EMBEDDING_PROTOCOLS } from "./protocol.ts" +export { DEFAULT_EMBEDDING_ENDPOINT, DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_PROTOCOL, MNEMON_EMBEDDING_PROTOCOLS } from 'dsh-mnemon-source-memory-spaces/contracts' export const DEFAULT_TIMEOUT_MS = 10_000 export const DEFAULT_RECALL_LIMIT = 10 diff --git a/src/host/config.ts b/src/host/config.ts index aeba3864..2466e41d 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -4,7 +4,7 @@ import { isAbsolute } from 'node:path' import { normalizeDisplayMode } from './display-mode.ts' import { schema as MemoryViewConfig, preferences as validateMemoryViewPreferences } from './view-preferences.ts' import { resolveEmbedding, resolvePersistenceStrategy, resolveRecallQuality } from 'dsh-mnemon-source-memory-spaces' -import { MEMORY_SPACE_ID_PATTERN } from 'dsh-mnemon-source-memory-spaces/contracts' +import { MEMORY_PLACEMENT_CAPABILITIES, MEMORY_SPACE_ID_PATTERN } from 'dsh-mnemon-source-memory-spaces/contracts' export { resolveEmbedding, resolvePersistenceStrategy, resolveRecallQuality } from 'dsh-mnemon-source-memory-spaces' import { @@ -72,8 +72,6 @@ export const InteractionConfig: z = z.object({ saveAction: z.boolean().default(true), }) -const MEMORY_PLACEMENT_CAPABILITIES = ['graph', 'entities', 'related', 'exact-write', 'link', 'forget'] as const - const MemoryProviderConnectionSchema: z = z.dict(z.union([z.string(), z.number(), z.boolean()])) const MemoryPersistenceStrategySchema = z.object({ mode: z.union(['manual', 'automatic'] as const), @@ -164,6 +162,9 @@ export const Config: z = z.object({ dataDir: z.string(), })).default([]), store: z.string(), + // Memory Spaces' own configuration takes 1–600,000 ms for the same value. One range for both is + // on the roadmap (docs/plans/review-gaps-2026-10.md, item 4); until then each keeps its own, + // so that stored values validate as they did. timeoutMs: z.number().step(1).min(100).max(120_000).default(DEFAULT_TIMEOUT_MS), defaultRecallLimit: z.number().step(1).min(1).max(50).default(DEFAULT_RECALL_LIMIT), runtimeMemory: RuntimeMemorySchema.default({ diff --git a/src/host/github-auth.ts b/src/host/github-auth.ts index b9e188f6..66abb3a5 100644 --- a/src/host/github-auth.ts +++ b/src/host/github-auth.ts @@ -17,7 +17,7 @@ * response body. */ import { createGitHubTransport, describeTransportError, type MnemonGitHubFetch } from './github-transport.ts' -import type { MnemonSyncGitHubPoll, MnemonSyncGitHubRepository, MnemonSyncGitHubRepositoryList, MnemonSyncGitHubStatus } from './protocol.ts' +import { githubRepositoryNameValid, type MnemonSyncGitHubPoll, type MnemonSyncGitHubRepository, type MnemonSyncGitHubRepositoryList, type MnemonSyncGitHubStatus } from './protocol.ts' import { MnemonSyncError } from './sync-error.ts' export const MNEMON_GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code' @@ -43,7 +43,6 @@ export const MNEMON_GITHUB_REPOSITORY_PAGE = 100 export const MNEMON_GITHUB_REPOSITORY_PAGES = 5 export const MNEMON_GITHUB_USER_AGENT = 'dsh-mnemon-sync' const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code' -const REPOSITORY_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/u /** What one stored grant holds. The token is the only field the channel needs. */ export interface MnemonGitHubGrant { @@ -403,7 +402,7 @@ export class MnemonGitHubAuth { async create(name: unknown, isPrivate: unknown, signal?: AbortSignal): Promise { const token = await this.requireToken() const value = typeof name === 'string' ? name.trim() : '' - if (!REPOSITORY_NAME.test(value)) throw new Error('a GitHub repository name holds letters, digits, dots, dashes and underscores') + if (!githubRepositoryNameValid(value)) throw new Error('a GitHub repository name holds letters, digits, dots, dashes and underscores') const { status, body } = await this.ask(MNEMON_GITHUB_API_BASE + '/user/repos', { method: 'POST', headers: { ...this.authorization(token), 'content-type': 'application/json' }, diff --git a/src/host/machine-identity.ts b/src/host/machine-identity.ts index b2975210..65adf7db 100644 --- a/src/host/machine-identity.ts +++ b/src/host/machine-identity.ts @@ -10,14 +10,12 @@ import { randomUUID } from 'node:crypto' import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { hostname } from 'node:os' import { join, resolve } from 'node:path' -import { MNEMON_MACHINE_LABEL_MAX, type MnemonMachineIdentity, type MnemonMachineLabelSource } from './protocol.ts' +import { machineLabelValid, MNEMON_MACHINE_LABEL_MAX, type MnemonMachineIdentity, type MnemonMachineLabelSource } from './protocol.ts' import type { StorageRoot } from './storage-root.ts' export const MNEMON_MACHINE_DIRECTORY = 'state' export const MNEMON_MACHINE_FILE = 'machine.json' const MNEMON_MACHINE_VERSION = 1 -// A name is shown on one line of a history row, so it carries no control characters. -const CONTROL = /[\p{Cc}\u2028\u2029]/u function record(value: unknown): Record | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : undefined @@ -33,9 +31,7 @@ export function defaultMachineLabel(): string { /** A name a person gives this machine, as it is stored and shown. */ export function machineLabel(value: unknown): string { const label = typeof value === 'string' ? value.trim() : '' - if (label === '' || label.length > MNEMON_MACHINE_LABEL_MAX || CONTROL.test(label)) { - throw new Error('machine name must be 1 to ' + String(MNEMON_MACHINE_LABEL_MAX) + ' characters on one line') - } + if (!machineLabelValid(label)) throw new Error('machine name must be 1 to ' + String(MNEMON_MACHINE_LABEL_MAX) + ' characters on one line') return label } diff --git a/src/host/protocol.ts b/src/host/protocol.ts index 60c7d25a..b34cbe81 100644 --- a/src/host/protocol.ts +++ b/src/host/protocol.ts @@ -33,7 +33,10 @@ import type { MemorySpaceView, } from 'dsh-mnemon-source-memory-spaces/contracts' import type { ConnectionHandle as DshClientConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { DocumentSnapshot } from 'dsh-mnemon-source-documents/contracts' +import type { DocumentMutationResult, DocumentSnapshot } from 'dsh-mnemon-source-documents/contracts' +import type { RuntimeMemoryMutationResult } from 'dsh-mnemon-source-runtime/contracts' +import type { MemoryMigrationLineage, MemoryMutationReceipt } from '../core/contracts/index.ts' +import { MNEMON_VIEW_CHANNEL, MNEMON_VIEW_WRITE_CHANNEL } from './view-protocol.ts' export const MNEMON_READ_CHANNEL = '/dsh-mnemon-read' export const MNEMON_ACTIVATION_CHANNEL = '/dsh-mnemon-activation' @@ -79,6 +82,39 @@ export const MNEMON_SETTINGS_NAMESPACE = 'mnemon' export const MNEMON_UI_SETTINGS_NAMESPACE = 'mnemon-ui' export * from './view-protocol.ts' export * from './memory-tools.ts' +export * from './sync-rules.ts' + +/** + * Every endpoint the Host serves on each channel, by the names both sides use: the client's calls + * are typed against this list, and tests/rpc-endpoints.spec.ts checks that each Host handler serves + * exactly these names. + */ +export const MNEMON_RPC_ENDPOINTS = { + [MNEMON_READ_CHANNEL]: [ + 'status', 'status-summary', 'memory-system', 'versions', 'task-agent-models', 'runtime-memory', 'documents', 'document', 'document-search', + 'graph', 'bodies', 'body-directory', 'body-reconnect', 'provider-services', 'embedding-status', 'list', 'entities', 'search', 'agent-search', 'related', + 'turn-activities', 'turn-activity', 'assistant-message', 'source-management-catalog', 'source-management-read', 'source-assistance', + ], + [MNEMON_ACTIVATION_CHANNEL]: ['body', 'source-assistance'], + [MNEMON_WRITE_CHANNEL]: [ + 'runtime-memory', 'document', 'supervise', 'remember', 'link', 'forget', 'body-create', 'body-update', 'body-delete', 'body-merge', + 'body-metadata-maintain', 'provider-service-update', 'embedding-fill', 'source-management-mutate', 'source-assistance', 'version-update', + ], + [MNEMON_PACK_CHANNEL]: ['target', 'export', 'inspect', 'import'], + [MNEMON_SYNC_CHANNEL]: [ + 'status', 'configure', 'push', 'preview', 'backups', 'diff', 'pull', + 'github-status', 'github-start', 'github-poll', 'github-cancel', 'github-signout', 'github-repositories', 'github-create', + ], + [MNEMON_REVIEW_CHANNEL]: ['view', 'opinion', 'decide', 'reopen', 'reconcile', 'apply'], + [MNEMON_SETTINGS_CHANNEL]: ['get', 'mutate'], + [MNEMON_VIEW_CHANNEL]: ['dashboard', 'preview', 'inspect-plugin'], + [MNEMON_VIEW_WRITE_CHANNEL]: ['apply', 'install-plugin'], +} as const satisfies Readonly> +/** A channel the Host serves. */ +export type MnemonRpcChannel = keyof typeof MNEMON_RPC_ENDPOINTS +/** An endpoint the Host serves on a channel, or on any of several. */ +export type MnemonRpcEndpoint = (typeof MNEMON_RPC_ENDPOINTS)[Channel][number] + export type JsonPrimitive = string | number | boolean | null export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue } @@ -529,6 +565,26 @@ export interface DelegatedWriteResult { /** The `supervise` RPC result: a delegated write for the conversation that asked for it. */ export interface SupervisedWritebackResult extends DelegatedWriteResult { sessionId: string } +/** What a task Agent answered from the evidence a search recalled, as `agent-search` adds it to the results. */ +export interface DelegatedAnswerResult { + answer: string + citations: string[] + delegation: { runId: string; provider: string } +} + +/** A Document write or archive the Host ran: the Documents Source's result, and the archival it ran, for the archive or to make room for the write. */ +export type CoordinatedDocumentResult = DocumentMutationResult & { + lineage?: MemoryMigrationLineage[] + maintenance?: { runId: string; provider: string; summary: string; memoryBodyIds: string[]; archivedDocumentIds: string[] } +} + +/** A runtime memory write the Host ran: the Runtime Source's result, and the capacity maintenance that ran with it. */ +export type CoordinatedRuntimeMemoryResult = RuntimeMemoryMutationResult & { + revision?: string + maintenance?: { runId: string; provider: string; summary: string; memoryBodyIds: string[] } + memoryReceipt?: Pick +} + export type LifecyclePhase = 'idle' | 'prime' | 'recall' | 'writeback' | 'review' | 'supervised' | 'error' export interface LifecycleCounters { @@ -709,9 +765,6 @@ export interface MnemonMachineIdentity { */ export type MnemonMachineLabelSource = 'hostname' | 'custom' -/** The longest name an installation carries; a host name past it is shortened. */ -export const MNEMON_MACHINE_LABEL_MAX = 120 - /** Which machine wrote one runtime entry, so a merge can tell two installations apart. */ export interface MnemonEntryOrigin { machine: string @@ -1264,13 +1317,3 @@ export interface VersionUpdateResult { restartRequired: boolean output?: string } - -// Default-product form values; Source configuration is validated again by its owner. -export const CATEGORIES = ['preference', 'decision', 'fact', 'insight', 'context', 'general'] as const -export const SOURCES = ['user', 'agent', 'external'] as const -export const EDGE_TYPES = ['temporal', 'semantic', 'causal', 'entity'] as const -export const INTENTS = ['WHY', 'WHEN', 'ENTITY', 'GENERAL'] as const -export const DEFAULT_EMBEDDING_ENDPOINT = 'http://localhost:11434' -export const DEFAULT_EMBEDDING_MODEL = 'nomic-embed-text' -export const DEFAULT_EMBEDDING_PROTOCOL = 'auto' -export const MNEMON_EMBEDDING_PROTOCOLS = ['auto', 'ollama', 'openai'] as const diff --git a/src/host/remote-rpc.ts b/src/host/remote-rpc.ts index 609c4e68..c93f70b6 100644 --- a/src/host/remote-rpc.ts +++ b/src/host/remote-rpc.ts @@ -1,7 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { HostRpcHandler, RpcResult } from './dsh.ts' -import { MNEMON_REMOTE_NAMESPACE } from './protocol.ts' +import { MNEMON_REMOTE_NAMESPACE, type MNEMON_SETTINGS_CHANNEL, type MnemonRpcEndpoint } from './protocol.ts' export interface MnemonRemoteHandlers { readonly read: HostRpcHandler @@ -27,6 +27,12 @@ function denied(): RpcResult { } } +/** + * What a remote page needs to reach each settings endpoint. An endpoint added to the settings + * channel does not compile here until it is given one, so none becomes remote without a decision. + */ +const REMOTE_SETTINGS: Readonly, 'read' | 'management'>> = { get: 'read', mutate: 'management' } + function unknownSettingsEndpoint(endpoint: string): RpcResult { return { ok: false, @@ -84,8 +90,10 @@ export class MnemonRemoteService extends TypertRemoteService { @Remote('settings') async settings(endpoint: string, payload: unknown, signal: AbortSignal): Promise> { - if (endpoint === 'mutate' && !this.handlers.management) return denied() - if (endpoint !== 'get' && endpoint !== 'mutate') return unknownSettingsEndpoint(endpoint) + // Object.hasOwn would read a non-string name, such as ["get"], as its text. + const access = typeof endpoint === 'string' && Object.hasOwn(REMOTE_SETTINGS, endpoint) ? REMOTE_SETTINGS[endpoint as keyof typeof REMOTE_SETTINGS] : undefined + if (access === 'management' && !this.handlers.management) return denied() + if (access === undefined) return unknownSettingsEndpoint(endpoint) const response = await this.handlers.settings(endpoint, payload, signal) if (endpoint !== 'get' || this.handlers.management || !response.ok || typeof response.value !== 'object' || response.value === null || Array.isArray(response.value)) return response return { ...response, value: { ...(response.value as Record), writable: false } } diff --git a/src/host/subagent.ts b/src/host/subagent.ts index 89208406..93c03745 100644 --- a/src/host/subagent.ts +++ b/src/host/subagent.ts @@ -8,7 +8,7 @@ import { SourceSession, sourceFailure } from './source-session.ts' import { runtimeCapacityWrite, strategyDrives, viewStrategy } from './strategy-workflows.ts' import { receipt as mutationReceipt } from '../sdk/input.ts' import { allowsParticipation, assertParticipation } from './access.ts' -import { namedToolInstance, type DelegatedWriteResult, type MemorySpaceMetadataMaintenanceResult, type MemorySpaceMetadataUpdate, type MemoryPlacementDecision, type MnemonReconcileOptions, type MnemonReconcileResult, type SubagentCounters } from './protocol.ts' +import { namedToolInstance, type CoordinatedDocumentResult, type CoordinatedRuntimeMemoryResult, type DelegatedAnswerResult, type DelegatedWriteResult, type MemorySpaceMetadataMaintenanceResult, type MemorySpaceMetadataUpdate, type MemoryPlacementDecision, type MnemonReconcileOptions, type MnemonReconcileResult, type SubagentCounters } from './protocol.ts' import { emptyDocumentSnapshot, foreignMachines, parseReconcileResult, pendingOpinions, RECONCILE_PERSONA, RECONCILE_SCHEMA, reconcilePrompt, type MnemonReconcileEvidence } from './reconcile.ts' import { DEFAULT_MEMORY_VIEW_BUDGET, type ComposableMemoryView, type MemoryEvidence, type MemoryJsonValue, type MemoryMigrationLineage, type MemoryMutationReceipt, type MemoryOperationScope, type MemorySourceManagementRequest, type MemorySourceManagementResult } from '../core/contracts/index.ts' import type { MemoryCompositionGeneration } from '../core/composition.ts' @@ -20,7 +20,7 @@ import { startWithContinuationTurns } from './continuation-turn.ts' import { childComposition, startSubagent, type SubagentStartAgents } from './subagent-start.ts' import type { IdleReviewFailure, MemoryToolName } from './protocol.ts' -export type { DelegatedWriteResult, SubagentCounters } from "./protocol.ts" +export type { CoordinatedDocumentResult, CoordinatedRuntimeMemoryResult, DelegatedAnswerResult, DelegatedWriteResult, SubagentCounters } from "./protocol.ts" type AgentRuntimeSource = MnemonAgentRuntimeSource @@ -413,23 +413,6 @@ function withoutVector(receipts: readonly CapturedToolReceipt[]): Array<{ memory }) } -export type CoordinatedDocumentResult = DocumentMutationResult & { - lineage?: MemoryMigrationLineage[] - maintenance?: { runId: string; provider: string; summary: string; memoryBodyIds: string[]; archivedDocumentIds: string[] } -} - -export interface DelegatedAnswerResult { - answer: string - citations: string[] - delegation: { runId: string; provider: string } -} - -export type CoordinatedRuntimeMemoryResult = RuntimeMemoryMutationResult & { - revision?: string - maintenance?: { runId: string; provider: string; summary: string; memoryBodyIds: string[] } - memoryReceipt?: Pick -} - function object(value: unknown): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('memory subagent returned an invalid structured result') return value as Record diff --git a/src/host/sync-config.ts b/src/host/sync-config.ts index 64d8ad22..77622cf0 100644 --- a/src/host/sync-config.ts +++ b/src/host/sync-config.ts @@ -3,7 +3,7 @@ import { isAbsolute, join, resolve } from 'node:path' import { MNEMON_SYNC_DEFAULT_AUTHOR_EMAIL, MNEMON_SYNC_DEFAULT_AUTHOR_NAME, MNEMON_SYNC_DEFAULT_BRANCH, MNEMON_SYNC_DEFAULT_SUBDIR, - MNEMON_SYNC_TOKEN_ENV, type MnemonSyncConfigView, + MNEMON_SYNC_TOKEN_ENV, syncBranchValid, syncDirectoryParts, syncEmailValid, syncRepositoryProblem, type MnemonSyncConfigView, } from './protocol.ts' import type { StorageRoot } from './storage-root.ts' @@ -20,13 +20,6 @@ export const MNEMON_SYNC_MAX_AUTO_BACKUP_MINUTES = 10_080 const STATE_DIRECTORY = 'state' const SETTING_KEYS = ['repoUrl', 'branch', 'subdir', 'token', 'authorName', 'authorEmail', 'autoBackupMinutes'] as const -const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u -const HTTPS = /^https:\/\/[^\s/]+(?::\d+)?\/[^\s]+$/u -const SSH = /^ssh:\/\/[^\s/]+(?::\d+)?\/[^\s]+$/u -const SCP = /^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s]+$/u -const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u -const EMBEDDED_CREDENTIAL = /^https:\/\/[^/]*@/u -const EMAIL = /^[^\s<>@]+@[^\s<>@]+$/u const REPOSITORY_HINT = 'sync repository must be an https:// or ssh:// URL, a git@host:path address, or an absolute local path' function record(value: unknown): Record | undefined { @@ -37,40 +30,32 @@ function known(key: string): boolean { return (SETTING_KEYS as readonly string[]).includes(key) } -/** A Git branch name: no revision syntax, no empty path segment, no trailing dot or lock suffix. */ +/** A Git branch name, by the rule the settings form checks too. */ export function syncBranch(value: unknown): string { const name = String(value ?? '').trim() - if (name === '' || name.length > 200 || !BRANCH.test(name) || name.includes('..') || name.includes('//') - || name.includes('@{') || name.includes('\\') || name.startsWith('/') || name.endsWith('/') - || name.endsWith('.') || name.endsWith('.lock') || name === '@') { - throw new Error('sync branch must be a Git branch name') - } + if (!syncBranchValid(name)) throw new Error('sync branch must be a Git branch name') return name } /** The directory inside the branch the payload lives in, always relative and slash terminated. */ export function syncSubdirectory(value: unknown): string { - const raw = String(value ?? '').trim().replaceAll('\\', '/') - if (raw === '' || raw.length > 400 || /^[A-Za-z]:/u.test(raw) || raw.includes('\0')) { - throw new Error('sync directory must be a relative path inside the branch') - } - const parts = raw.split('/').filter(part => part !== '') - if (parts.length === 0 || parts.some(part => part === '.' || part === '..' || part.includes(':'))) { - throw new Error('sync directory must be a relative path inside the branch') - } + const text = String(value ?? '') + // The settings form shares the rule but has never refused a NUL byte, which only the Host does. + const parts = text.includes('\0') ? undefined : syncDirectoryParts(text) + if (parts === undefined) throw new Error('sync directory must be a relative path inside the branch') return parts.join('/') + '/' } -/** A remote this Host can run Git against, never carrying credentials in its own text. */ +/** + * A remote this Host can run Git against, never carrying credentials in its own text. A local path + * is absolute by this Host's platform, which the settings form cannot know; as with a directory, + * only the Host refuses a NUL byte. + */ export function syncRepositoryUrl(value: unknown): string { const url = String(value ?? '').trim() - if (url === '' || url.length > 2000 || /\s/u.test(url) || url.includes('\0')) throw new Error(REPOSITORY_HINT) - if (SCHEME.test(url)) { - if (!HTTPS.test(url) && !SSH.test(url)) throw new Error(REPOSITORY_HINT) - if (EMBEDDED_CREDENTIAL.test(url)) throw new Error('sync repository must not embed credentials; set the token instead') - } else if (!SCP.test(url) && !isAbsolute(url)) { - throw new Error(REPOSITORY_HINT) - } + const problem = url.includes('\0') ? 'format' : syncRepositoryProblem(url, isAbsolute) + if (problem === 'credentials') throw new Error('sync repository must not embed credentials; set the token instead') + if (problem !== undefined) throw new Error(REPOSITORY_HINT) return url } @@ -96,7 +81,7 @@ export function syncAuthorName(value: unknown): string { export function syncAuthorEmail(value: unknown): string { const email = String(value ?? '').trim() if (email === '') return '' - if (email.length > 320 || !EMAIL.test(email)) throw new Error('sync author email must be an email address') + if (!syncEmailValid(email)) throw new Error('sync author email must be an email address') return email } diff --git a/src/host/sync-rules.ts b/src/host/sync-rules.ts new file mode 100644 index 00000000..3666fc37 --- /dev/null +++ b/src/host/sync-rules.ts @@ -0,0 +1,75 @@ +/** + * The rules a Git sync setting meets, shared by the settings form, which checks a field as the user + * types it, and the Host, which checks what it saves and what it reads back. They import nothing, + * so the browser bundle takes them too; each side keeps its own messages. + */ + +/** The longest name an installation carries; a host name past it is shortened. */ +export const MNEMON_MACHINE_LABEL_MAX = 120 + +const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u +const HTTPS = /^https:\/\/[^\s/]+(?::\d+)?\/[^\s]+$/u +const SSH = /^ssh:\/\/[^\s/]+(?::\d+)?\/[^\s]+$/u +const SCP = /^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s]+$/u +const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u +const EMBEDDED_CREDENTIAL = /^https:\/\/[^/]*@/u +const EMAIL = /^[^\s<>@]+@[^\s<>@]+$/u +const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/u +// A name is shown on one line of a history row, so it carries no control characters. +const CONTROL = /[\p{Cc}\u2028\u2029]/u +const GITHUB_REPOSITORY = /^(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/u + +/** A Git branch name: no revision syntax, no empty path segment, no trailing dot or lock suffix. */ +export function syncBranchValid(name: string): boolean { + return name !== '' && name.length <= 200 && BRANCH.test(name) && !name.includes('..') && !name.includes('//') + && !name.includes('@{') && !name.includes('\\') && !name.startsWith('/') && !name.endsWith('/') + && !name.endsWith('.') && !name.endsWith('.lock') && name !== '@' +} + +/** The parts of a directory inside the branch, or undefined when it is not one: relative, with no `.`, `..` or drive. */ +export function syncDirectoryParts(value: string): string[] | undefined { + const raw = value.trim().replaceAll('\\', '/') + if (raw === '' || raw.length > 400 || /^[A-Za-z]:/u.test(raw)) return undefined + const parts = raw.split('/').filter(part => part !== '') + return parts.length === 0 || parts.some(part => part === '.' || part === '..' || part.includes(':')) ? undefined : parts +} + +/** What is wrong with a repository address. */ +export type MnemonSyncRepositoryProblem = 'format' | 'credentials' + +/** + * What is wrong with a repository address, or nothing for an https:// or ssh:// URL without + * credentials in it, a git@host:path address, or a local path that `localPath` takes. + */ +export function syncRepositoryProblem(url: string, localPath: (path: string) => boolean): MnemonSyncRepositoryProblem | undefined { + if (url === '' || url.length > 2000 || /\s/u.test(url)) return 'format' + if (SCHEME.test(url)) { + if (!HTTPS.test(url) && !SSH.test(url)) return 'format' + return EMBEDDED_CREDENTIAL.test(url) ? 'credentials' : undefined + } + return SCP.test(url) || localPath(url) ? undefined : 'format' +} + +/** + * A local path that is absolute on POSIX or on Windows, for the browser, which cannot tell which + * platform the Host runs on: a POSIX path, a Windows path on a drive, or a UNC share. The Host + * checks a path by its own platform's rule instead. + */ +export function portableAbsolutePath(path: string): boolean { + return path.startsWith('/') || WINDOWS_DRIVE.test(path) || path.startsWith('\\\\') +} + +/** A commit author's address. */ +export function syncEmailValid(email: string): boolean { + return email.length <= 320 && EMAIL.test(email) +} + +/** A name for this machine, as it is stored and shown: one line of at most MNEMON_MACHINE_LABEL_MAX characters. */ +export function machineLabelValid(label: string): boolean { + return label !== '' && label.length <= MNEMON_MACHINE_LABEL_MAX && !CONTROL.test(label) +} + +/** A GitHub repository name, as GitHub accepts one. */ +export function githubRepositoryNameValid(name: string): boolean { + return GITHUB_REPOSITORY.test(name) +} diff --git a/src/host/tools.ts b/src/host/tools.ts index e91206fd..6b47c0d7 100644 --- a/src/host/tools.ts +++ b/src/host/tools.ts @@ -1,8 +1,7 @@ import type { HostContextShape, ToolDefinition, ToolExecution } from './dsh.ts' import type { DocumentMutation } from 'dsh-mnemon-source-documents/contracts' import type { RuntimeMemoryImportance, RuntimeMemoryTarget } from 'dsh-mnemon-source-runtime/contracts' -import type { Category, EdgeType, Source } from 'dsh-mnemon-source-memory-spaces/contracts' -import { CATEGORIES, EDGE_TYPES, SOURCES } from './protocol.ts' +import { CATEGORIES, EDGE_TYPES, SOURCES, type Category, type EdgeType, type Source } from 'dsh-mnemon-source-memory-spaces/contracts' import { assertParticipation } from './access.ts' import type { MemoryCapability, MemoryJsonValue, MemoryMutationReceipt } from '../core/contracts/index.ts' import { unofferedViewId } from './view-presentation.ts' diff --git a/src/host/view-rpc.ts b/src/host/view-rpc.ts index 1727b752..48822306 100644 --- a/src/host/view-rpc.ts +++ b/src/host/view-rpc.ts @@ -7,6 +7,7 @@ import type { MemoryOperationScope } from '../core/contracts/index.ts' import type { MemoryPluginManagement } from './plugin-management.ts' import type { MemoryPluginInstallation } from './plugin-installation.ts' import { MNEMON_VIEW_CHANNEL, MNEMON_VIEW_WRITE_CHANNEL, type MemoryViewConfigurationRequest, type MemoryViewDashboard } from './view-protocol.ts' +import { MNEMON_RPC_ENDPOINTS } from './protocol.ts' import { mainStrategyTypeId } from './main-strategy.ts' function object(value: unknown): Record { @@ -20,9 +21,10 @@ function optionalId(value: unknown): string | undefined { } export function createViewHandler(runtime: LiveMnemonRuntime, engine: MemoryRuntime, management: MemoryPluginManagement, access: 'read' | 'write', lifecycle?: MnemonLifecycle, installation?: MemoryPluginInstallation): HostRpcHandler { + const served: readonly string[] = MNEMON_RPC_ENDPOINTS[access === 'read' ? MNEMON_VIEW_CHANNEL : MNEMON_VIEW_WRITE_CHANNEL] return async (endpoint, input, signal) => { try { - if (access === 'read' ? !['dashboard', 'preview', 'inspect-plugin'].includes(endpoint) : !['apply', 'install-plugin'].includes(endpoint)) throw new Error('View operation is not available on this channel') + if (!served.includes(endpoint)) throw new Error('View operation is not available on this channel') const payload = object(input) if (endpoint === 'inspect-plugin') { if (installation === undefined || typeof payload.packageName !== 'string') throw new Error('Plugin discovery is unavailable') diff --git a/tests/client-platform-boundary.spec.ts b/tests/client-platform-boundary.spec.ts index 6657297d..f0981afb 100644 --- a/tests/client-platform-boundary.spec.ts +++ b/tests/client-platform-boundary.spec.ts @@ -37,8 +37,24 @@ describe('browser bundle platform boundary', () => { }) it('keeps the shared browser contract free of Node runtime imports', () => { - const contract = readFileSync(new URL('../src/host/protocol.ts', import.meta.url), 'utf8') - expect(contract).not.toMatch(/from\s*['"]node:/u) + // protocol.ts and every module it takes values from or re-exports, as the browser bundle loads them. + const pending = [join(root, 'src/host/protocol.ts')] + const visited = new Set() + while (pending.length > 0) { + const file = pending.pop()! + if (visited.has(file)) continue + visited.add(file) + const source = readFileSync(file, 'utf8') + expect(source, file).not.toMatch(/from\s*['"]node:/u) + for (const node of ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true).statements) { + if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || node.moduleSpecifier === undefined || !ts.isStringLiteral(node.moduleSpecifier)) continue + const typeOnly = ts.isImportDeclaration(node) ? node.importClause?.isTypeOnly === true : node.isTypeOnly + if (!typeOnly && node.moduleSpecifier.text.startsWith('.')) pending.push(resolve(dirname(file), node.moduleSpecifier.text)) + } + } + expect([...visited].map(file => file.slice(root.length).split(sep).join('/')).sort()).toEqual([ + 'src/host/display-mode.ts', 'src/host/memory-tools.ts', 'src/host/protocol.ts', 'src/host/starter-components.ts', 'src/host/sync-rules.ts', 'src/host/view-protocol.ts', + ]) }) it('delegates deployment URLs and transport to the host connection', () => { diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 258fd34d..f64db79f 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -1,4 +1,9 @@ +import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { MEMORY_PLACEMENT_CAPABILITIES } from 'dsh-mnemon-source-memory-spaces/contracts' +import { Config as RuntimeConfig } from 'dsh-mnemon-source-runtime' +import { RUNTIME_MEMORY_LIMIT_MAX_BYTES, RUNTIME_MEMORY_LIMITS } from 'dsh-mnemon-source-runtime/contracts' import { Config, resolveConfig } from "../src/host/config.ts" afterEach(() => vi.unstubAllEnvs()) @@ -121,6 +126,42 @@ describe('Mnemon config and resolution', () => { expect(() => resolveConfig({ runtimeMemory: { maintenanceMaxTokens: 1_000_001 } })).toThrow('maintenance maxTokens') }) + it('defaults and bounds the Runtime limits as the Runtime Source does', () => { + // The values stored settings are checked against. + expect([RUNTIME_MEMORY_LIMITS.memory, RUNTIME_MEMORY_LIMITS.user, RUNTIME_MEMORY_LIMIT_MAX_BYTES]).toEqual([10_240, 4_096, 1_048_576]) + expect(resolveConfig({}).runtimeMemory).toMatchObject({ memoryLimitBytes: RUNTIME_MEMORY_LIMITS.memory, userLimitBytes: RUNTIME_MEMORY_LIMITS.user }) + expect(Config({}).runtimeMemory).toMatchObject({ memoryLimitBytes: RUNTIME_MEMORY_LIMITS.memory, userLimitBytes: RUNTIME_MEMORY_LIMITS.user }) + for (const [key, file] of [['memoryLimitBytes', 'MEMORY.md'], ['userLimitBytes', 'USER.md']] as const) { + expect(resolveConfig({ runtimeMemory: { [key]: RUNTIME_MEMORY_LIMIT_MAX_BYTES } }).runtimeMemory[key]).toBe(RUNTIME_MEMORY_LIMIT_MAX_BYTES) + expect(RuntimeConfig({ [key]: RUNTIME_MEMORY_LIMIT_MAX_BYTES })[key]).toBe(RUNTIME_MEMORY_LIMIT_MAX_BYTES) + expect(() => resolveConfig({ runtimeMemory: { [key]: RUNTIME_MEMORY_LIMIT_MAX_BYTES + 1 } })) + .toThrow(`dsh-mnemon: Runtime ${file} limit must be an integer within 1..${RUNTIME_MEMORY_LIMIT_MAX_BYTES} bytes`) + expect(() => Config({ runtimeMemory: { [key]: RUNTIME_MEMORY_LIMIT_MAX_BYTES + 1 } })).toThrow(`$.runtimeMemory.${key} expected number <= 1048576 but got 1048577`) + expect(() => RuntimeConfig({ [key]: RUNTIME_MEMORY_LIMIT_MAX_BYTES + 1 })).toThrow(`$.${key} expected number <= 1048576 but got 1048577`) + } + }) + + it('takes the placement capabilities Memory Spaces lists, and only those', () => { + const persistenceStrategy = { mode: 'automatic' as const, rules: { requiredCapabilities: [...MEMORY_PLACEMENT_CAPABILITIES] } } + expect(Config({ persistenceStrategy }).persistenceStrategy?.rules?.requiredCapabilities).toEqual([...MEMORY_PLACEMENT_CAPABILITIES]) + expect(resolveConfig({ persistenceStrategy }).persistenceStrategy.rules.requiredCapabilities).toEqual([...MEMORY_PLACEMENT_CAPABILITIES]) + expect(() => Config({ persistenceStrategy: { rules: { requiredCapabilities: ['telepathy' as never] } } })) + .toThrow('$.persistenceStrategy.rules.requiredCapabilities[0] expected "graph" | "entities" | "related" | "exact-write" | "link" | "forget" but got "telepathy"') + }) + + it('writes only the code defaults into the Starter patch', () => { + // The mnemon Entry of the bundle patch, its disabled expression kept as data. + const rows = parse(readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8'), { + customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => ({ __jsExpr: value }) }], + }) as Array<{ insert: Array<{ config: Array<{ id: string; config?: Record }> }> }> + const patched = rows[0]!.insert[0]!.config.find(row => row.id === 'mnemon')!.config! + expect(Object.keys(patched).sort()).toEqual(['defaultRecallLimit', 'embedding', 'idleReviewMs', 'lifecycleEnabled', 'recallMode', 'recallQuality', + 'remoteAccess', 'routingGuidance', 'tabEnabled', 'timeoutMs', 'writeEnabled', 'writebackMode']) + // The defaults DSH's form shows, and the ones the Host applies, which take embedding and recall quality from Memory Spaces. + expect(Config({})).toMatchObject(patched) + expect(resolveConfig({})).toMatchObject(patched) + }) + it('inherits the DSH new-session model by default and validates fixed task routes', () => { expect(resolveConfig({}).taskAgentModel).toEqual({ mode: 'inherit' }) expect(resolveConfig({ diff --git a/tests/remote-rpc.spec.ts b/tests/remote-rpc.spec.ts index 52652283..cd356197 100644 --- a/tests/remote-rpc.spec.ts +++ b/tests/remote-rpc.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { HostRpcHandler } from '../src/host/dsh.ts' +import { MNEMON_RPC_ENDPOINTS, MNEMON_SETTINGS_CHANNEL } from '../src/host/protocol.ts' import { MnemonRemoteService } from '../src/host/remote-rpc.ts' function handler(value: string): HostRpcHandler { @@ -29,6 +30,11 @@ describe('Mnemon API Gateway service', () => { await expect(service.pack('export', {}, signal)).resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) await expect(service.sync('status', {}, signal)).resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) await expect(service.viewWrite('apply', {}, signal)).resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + // Reading the review ledger needs the grant too, as the Interfaces reference says. + for (const endpoint of ['view', 'opinion', 'decide', 'reopen', 'reconcile', 'apply']) { + await expect(service.review(endpoint, {}, signal)).resolves.toMatchObject({ ok: false, error: { code: 'bad-request', message: 'remote Mnemon management requires remoteAccess: trusted-host' } }) + } + expect(handlers.review).not.toHaveBeenCalled() expect(handlers.read).toHaveBeenCalledWith('status-summary', { workspaceId: 'workspace' }, signal) expect(handlers.view).toHaveBeenCalledWith('dashboard', {}, signal) expect(handlers.activation).toHaveBeenCalledWith('body', { active: true }, signal) @@ -49,4 +55,35 @@ describe('Mnemon API Gateway service', () => { expect(handlers.viewWrite).toHaveBeenCalledWith('apply', {}, signal) expect(handlers.settings).toHaveBeenCalledWith('mutate', {}, signal) }) + + it('reaches exactly the settings endpoints the Host serves, and writes only with the grant', async () => { + const reader = fixture(false) + const manager = fixture(true) + for (const endpoint of MNEMON_RPC_ENDPOINTS[MNEMON_SETTINGS_CHANNEL]) { + await expect(manager.service.settings(endpoint, {}, manager.signal)).resolves.toEqual({ ok: true, value: `settings:${endpoint}` }) + } + await expect(reader.service.settings('get', {}, reader.signal)).resolves.toEqual({ ok: true, value: 'settings:get' }) + await expect(reader.service.settings('mutate', {}, reader.signal)).resolves.toMatchObject({ ok: false, error: { message: 'remote Mnemon management requires remoteAccess: trusted-host' } }) + for (const endpoint of ['reset', 'Get', 'constructor', 'toString', '__proto__']) { + for (const { service, signal } of [reader, manager]) { + await expect(service.settings(endpoint, {}, signal)).resolves.toEqual({ + ok: false, error: { code: 'bad-request', message: `unknown remote Mnemon settings endpoint: ${endpoint}`, details: { issues: [] } }, + }) + } + } + expect(reader.handlers.settings).toHaveBeenCalledTimes(1) + expect(manager.handlers.settings).toHaveBeenCalledTimes(2) + }) + + it('refuses a settings endpoint that is not a string as unknown, without asking the settings handler', async () => { + for (const management of [false, true]) { + const { service, handlers, signal } = fixture(management) + for (const endpoint of [['get'], ['mutate'], 7, null]) { + await expect(service.settings(endpoint as unknown as string, {}, signal)).resolves.toEqual({ + ok: false, error: { code: 'bad-request', message: `unknown remote Mnemon settings endpoint: ${String(endpoint)}`, details: { issues: [] } }, + }) + } + expect(handlers.settings).not.toHaveBeenCalled() + } + }) }) diff --git a/tests/rpc-endpoints.spec.ts b/tests/rpc-endpoints.spec.ts new file mode 100644 index 00000000..1c7e385a --- /dev/null +++ b/tests/rpc-endpoints.spec.ts @@ -0,0 +1,230 @@ +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { describe, expect, it, vi } from 'vitest' +import { MnemonClient } from '../src/client/api.ts' +import { createComponentHosts } from '../src/client/component-host.ts' +import { MnemonSettingsScope } from '../src/client/settings.ts' +import { resolveConfig } from '../src/host/config.ts' +import type { HostConnectionHandle, HostRpcHandler, HostSettingsService, RpcResult } from '../src/host/dsh.ts' +import type { MnemonLifecycle } from '../src/host/lifecycle.ts' +import type { MemoryPluginManagement } from '../src/host/plugin-management.ts' +import { + MNEMON_ACTIVATION_CHANNEL, MNEMON_PACK_CHANNEL, MNEMON_READ_CHANNEL, MNEMON_REVIEW_CHANNEL, MNEMON_RPC_ENDPOINTS, MNEMON_SETTINGS_CHANNEL, + MNEMON_SYNC_CHANNEL, MNEMON_VIEW_CHANNEL, MNEMON_VIEW_WRITE_CHANNEL, MNEMON_WRITE_CHANNEL, + type ClientConnectionHandle, type ClientSettingsScope, type Config, type MnemonRpcChannel, +} from '../src/host/protocol.ts' +import { registerRpc } from '../src/host/rpc.ts' +import type { LiveMnemonRuntime } from '../src/host/runtime.ts' +import { registerSettingsRpc } from '../src/host/settings.ts' +import type { VersionUpdateManager } from '../src/host/version-updates.ts' +import { registerViewRpc } from '../src/host/view-rpc.ts' +import type { MemoryRuntime } from '../src/core/runtime.ts' + +const channels = Object.keys(MNEMON_RPC_ENDPOINTS) as MnemonRpcChannel[] +/** Channel and endpoint pairs, each once, in a stable order. */ +const pairs = (entries: Iterable): string[] => [...new Set([...entries].map(([channel, endpoint]) => channel + ' ' + endpoint))].sort() +const LISTED = pairs(channels.flatMap(channel => MNEMON_RPC_ENDPOINTS[channel].map(endpoint => [channel, endpoint] as const))) + +/** + * What each Host handler answers an endpoint it does not serve. The View handlers check a name + * against the list first, so one listed there that nothing dispatches gets their second answer. + */ +const VIEW_UNKNOWN = ['View operation is not available on this channel', 'Unknown View operation'] +const UNKNOWN: Record readonly string[]> = { + [MNEMON_READ_CHANNEL]: endpoint => ['unknown read endpoint: ' + endpoint], + [MNEMON_ACTIVATION_CHANNEL]: endpoint => ['unknown activation endpoint: ' + endpoint], + [MNEMON_WRITE_CHANNEL]: endpoint => ['unknown write endpoint: ' + endpoint], + [MNEMON_PACK_CHANNEL]: endpoint => ['unknown Pack endpoint: ' + endpoint], + [MNEMON_SYNC_CHANNEL]: endpoint => ['unknown sync endpoint: ' + endpoint], + [MNEMON_REVIEW_CHANNEL]: endpoint => ['unknown review endpoint: ' + endpoint], + [MNEMON_SETTINGS_CHANNEL]: endpoint => ['unknown settings endpoint: ' + endpoint], + [MNEMON_VIEW_CHANNEL]: () => VIEW_UNKNOWN, + [MNEMON_VIEW_WRITE_CHANNEL]: () => VIEW_UNKNOWN, +} + +/** + * Every lowercase word the Host's handler modules spell, as a string or a name: more than the + * endpoint names they compare, so a name served without being listed is found too. + */ +function handlerWords(): string[] { + const words = new Set() + for (const file of ['src/host/rpc.ts', 'src/host/settings.ts', 'src/host/view-rpc.ts']) { + const visit = (node: ts.Node): void => { + if ((ts.isStringLiteralLike(node) || ts.isIdentifier(node)) && /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(node.text)) words.add(node.text) + ts.forEachChild(node, visit) + } + visit(ts.createSourceFile(file, readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)) + } + return [...words] +} + +/** + * The handlers the Host registers, over just enough of a Host for each to reach its dispatch: + * stores, Agents, Git and the registry are stand-ins that answer at once. + */ +function hostHandlers(): Map { + const answer = vi.fn(async () => ({})) + const source = { read: answer, mutate: answer, identity: vi.fn(async () => ({ sourceInstanceKey: 'source:mnemon-source-runtime' })) } + const generation = { + managementCatalog: vi.fn(async () => ({ generationId: 'g1', sources: [] })), + managementRevision: vi.fn(async () => 'r1'), + executeManagement: vi.fn(async () => ({ revision: 'r1', value: {} })), + sourceInstances: () => [], + strategy: { definition: { manifest: { typeId: 'default-three-tier' } } }, + } + const config = resolveConfig({}) + const graph = { + config, directory: '/fixture/data', source: () => source, + memoryComposition: { current: () => generation, acquire: () => ({ generation, release: () => {} }), inspect: () => ({ evaluation: { state: 'ready' } }) }, + storage: { catalog: () => ({}) }, + packs: { target: () => ({}), exportPack: answer, inspectPack: () => ({}), importPack: answer }, + sync: { status: answer, configure: answer, push: answer, preview: answer, backups: answer, diff: answer, pull: answer }, + } + const route = { graph, selectedRoot: '/fixture/data', effectiveRoot: '/fixture/data', aligned: true, liveSession: false } + const runtime = { + config, route: () => route, + sync: { github: () => ({ status: answer, start: answer, poll: answer, cancel: answer, signOut: answer, repositories: answer, create: answer }) }, + reviews: { view: () => ({}), addOpinion: () => ({}), decide: () => ({}), reopen: () => ({}), get: () => undefined }, + } as unknown as LiveMnemonRuntime + const lifecycle = { + workspaceRoot: () => '/fixture/workspace', snapshot: () => ({ enabled: true, taskAgentAvailable: true }), + taskAgentModels: answer, answerTask: answer, superviseTask: answer, reconcile: answer, + manageSource: vi.fn(async () => ({ revision: 'r1', value: {} })), + turnActivities: () => ({ cursor: 0, activities: [] }), assistantMessage: () => null, memoryView: () => undefined, + } as unknown as MnemonLifecycle + const versions = { check: answer, update: answer, restartStatus: () => undefined, runningVersion: '0.0.0' } as unknown as VersionUpdateManager + const settings = { + writable: false, mutate: answer, + describe: () => [{ ns: 'mnemon', value: {}, base: {}, user: {}, revision: 1 }], + } as unknown as HostSettingsService + const management = { resolveConfig: (value: unknown) => value, catalog: async () => ({}) } as unknown as MemoryPluginManagement + const engine = { contributionSnapshot: () => ({ sources: [] }) } as unknown as MemoryRuntime + const handlers = new Map() + const connection = { rpc: { handle: (channel: string, handler: HostRpcHandler) => { handlers.set(channel, handler) } } } as unknown as HostConnectionHandle + registerRpc(connection, runtime, lifecycle, versions) + registerSettingsRpc(connection, settings) + registerViewRpc(connection, runtime, engine, management, lifecycle) + return handlers +} + +/** Every channel and endpoint the shipped client sends, from each of its callers. */ +async function clientCalls(): Promise> { + const calls: Array = [] + const connection = { + isLoopback: true, + rpc: { call: vi.fn(async (channel: string, endpoint: string) => { + calls.push([channel, endpoint]) + return { ok: true as const, value: endpoint === 'turn-activities' ? { cursor: 1, activities: [] } : {} } + }) }, + } as unknown as ClientConnectionHandle + + const client = new MnemonClient(connection, 'session-1', 'workspace-1') + const methods: Record Promise> = { + status: () => client.status(), + statusSummary: () => client.statusSummary(), + memorySystem: () => client.memorySystem(), + viewDashboard: () => client.viewDashboard(), + applyView: () => client.applyView({ expectedRevision: 'r1', strategyTypeId: 'default-three-tier', entries: {} }), + sourceManagementCatalog: () => client.sourceManagementCatalog(), + readSourceManagement: () => client.readSourceManagement('source:spaces', 'snapshot'), + mutateSourceManagement: () => client.mutateSourceManagement('source:spaces', 'mutate', {}, 'r1', true), + // Activation, a confirmed write and an unconfirmed read each take their own channel. + assistSource: () => Promise.all([ + client.assistSource('source:spaces', 'activation', {}, 'r1', true), + client.assistSource('source:spaces', 'supervise', {}, 'r1', true), + client.assistSource('source:spaces', 'agent-search', {}, 'r1', false), + ]), + taskAgentModels: () => client.taskAgentModels(), + versions: () => client.versions(), + updateVersion: () => client.updateVersion('mnemon'), + turnActivity: () => client.turnActivity(1), + assistantMessageText: () => client.assistantMessageText('message-1'), + supervise: () => client.supervise('A durable fact'), + packTarget: () => client.packTarget(), + exportPack: () => client.exportPack(), + inspectPack: () => client.inspectPack('eA=='), + importPack: () => client.importPack('eA=='), + reviewLedger: () => client.reviewLedger(), + reconcile: () => client.reconcile(), + reviewOpinion: () => client.reviewOpinion('entry-1', 'Keep it'), + decideReview: () => client.decideReview('entry-1', 'accepted'), + reopenReview: () => client.reopenReview('entry-1'), + applyReview: () => client.applyReview('entry-1'), + syncStatus: () => client.syncStatus(), + configureSync: () => client.configureSync({}), + pushSync: () => client.pushSync(), + previewSync: () => client.previewSync(), + syncBackups: () => client.syncBackups(), + syncDiff: () => client.syncDiff(), + pullSync: () => client.pullSync(), + githubStatus: () => client.githubStatus(), + githubStart: () => client.githubStart(), + githubPoll: () => client.githubPoll(), + githubCancel: () => client.githubCancel(), + githubSignOut: () => client.githubSignOut(), + githubRepositories: () => client.githubRepositories(), + githubCreateRepository: () => client.githubCreateRepository('memory', true), + } + // Every method that calls the Host, so one added later has to be added here too. The + // component operations run through the hosts a page lends components, below. + const own = Object.getOwnPropertyNames(MnemonClient.prototype).filter(name => !['constructor', 'call', 'scoped', 'componentOperation'].includes(name)) + expect(Object.keys(methods).sort()).toEqual(own.sort()) + for (const run of Object.values(methods)) await run() + + // A component's settings name the Host operations they want; the page serves only those it lends. + const scope: ClientSettingsScope = { getSnapshot: () => ({ status: 'ready', writable: true, mode: 'host' }), subscribe: () => () => {}, mutate: async () => {} } + const hosts = createComponentHosts({ scope, connection }) + const probes = [...new Set(Object.values(MNEMON_RPC_ENDPOINTS).flat())] + for (const packageName of ['dsh-mnemon-source-runtime', 'dsh-mnemon-source-documents', 'dsh-mnemon-source-memory-spaces']) { + const operations = hosts(packageName, 'session-1', 'workspace-1')?.operations + for (const operation of probes) { + await operations?.read(operation, {}).catch(() => undefined) + await operations?.write(operation, {}).catch(() => undefined) + } + } + + const settings = new MnemonSettingsScope(connection) + await settings.mutate([{ op: 'set', path: ['routingGuidance'], value: true }]) + return calls +} + +/** + * Endpoints the Host serves that the shipped client no longer calls. The workbench's pages moved to + * Source management and Host assistance in September 2026, and the turn bar to the bulk + * `turn-activities` read in August; the Host kept the direct endpoints, which the Interfaces + * reference still lists and scripts such as verify-sync-git.mjs use. + */ +const SERVED_FOR_OTHER_CALLERS = pairs([ + ...['runtime-memory', 'documents', 'document', 'document-search', 'graph', 'bodies', 'body-directory', 'body-reconnect', + 'list', 'entities', 'search', 'agent-search', 'related', 'turn-activity'].map(endpoint => [MNEMON_READ_CHANNEL, endpoint] as const), + [MNEMON_ACTIVATION_CHANNEL, 'body'], + ...['runtime-memory', 'document', 'remember', 'link', 'forget', 'body-create', 'body-update', 'body-delete', 'body-merge', + 'body-metadata-maintain'].map(endpoint => [MNEMON_WRITE_CHANNEL, endpoint] as const), + [MNEMON_VIEW_CHANNEL, 'preview'], [MNEMON_VIEW_CHANNEL, 'inspect-plugin'], [MNEMON_VIEW_WRITE_CHANNEL, 'install-plugin'], +]) + +describe('RPC endpoint names', () => { + it('has the Host serve exactly the listed endpoints on each channel it registers', async () => { + const handlers = hostHandlers() + expect([...handlers.keys()].sort()).toEqual([...channels].sort()) + // Every name any channel lists, and every word the handlers spell, against every channel. + const probes = [...new Set([...Object.values(MNEMON_RPC_ENDPOINTS).flat(), ...handlerWords(), 'unlisted-endpoint'])] + expect(probes.length).toBeGreaterThan(200) + const refused: Array<[string, string]> = [] + for (const channel of channels) { + for (const endpoint of probes) { + const response = await handlers.get(channel)!(endpoint, {}, new AbortController().signal) as RpcResult + if (!response.ok && UNKNOWN[channel](endpoint).includes(response.error.message)) refused.push([channel, endpoint]) + } + } + const unknown = new Set(pairs(refused)) + const served = pairs(channels.flatMap(channel => probes.map(endpoint => [channel, endpoint] as const))).filter(pair => !unknown.has(pair)) + expect(served).toEqual(LISTED) + }) + + it('has the client call only endpoints the Host serves, and the Host serve only those and the ones kept for other callers', async () => { + const called = pairs(await clientCalls()) + expect(called.filter(pair => !LISTED.includes(pair))).toEqual([]) + expect(LISTED.filter(pair => !called.includes(pair))).toEqual(SERVED_FOR_OTHER_CALLERS) + }) +}) diff --git a/tests/sync-rules.spec.ts b/tests/sync-rules.spec.ts new file mode 100644 index 00000000..a23c047a --- /dev/null +++ b/tests/sync-rules.spec.ts @@ -0,0 +1,92 @@ +import { posix, win32 } from 'node:path' +import { describe, expect, it } from 'vitest' +import { branchValid, directoryValid, emailValid, machineNameValid, repositoryNameValid, repositoryProblem } from '../src/client/sync-view.ts' +import { MNEMON_GITHUB_DEFAULT_CLIENT_ID, MnemonGitHubAuth } from '../src/host/github-auth.ts' +import { machineLabel } from '../src/host/machine-identity.ts' +import { MNEMON_MACHINE_LABEL_MAX, portableAbsolutePath, syncRepositoryProblem } from '../src/host/protocol.ts' +import { syncAuthorEmail, syncBranch, syncRepositoryUrl, syncSubdirectory } from '../src/host/sync-config.ts' + +/** Whether a Host check takes a value, which it refuses by throwing. */ +function takes(check: (value: unknown) => unknown, value: string): boolean { + try { + check(value) + return true + } catch { + return false + } +} + +const BRANCHES = ['mnemon-sync', 'user/mnemon-sync', 'v1.2', 'a', ' padded ', '', ' ', '-x', '.x', 'x.', 'x.lock', 'x/', '/x', 'a//b', 'a..b', 'a@{1}', '@', 'a\\b', 'a b', 'ä', 'x'.repeat(200), 'x'.repeat(201)] +const DIRECTORIES = ['mnemon', 'mnemon/', '/nested/memory/', 'a\\b', ' a ', '', '/', '.', '..', 'a/../b', 'a/./b', 'C:/x', 'c:x', 'a:b', 'x'.repeat(400), 'x'.repeat(401)] +const ADDRESSES = ['me@example.com', ' me@example.com ', 'a@b', 'me', '@example.com', 'me@', 'me@@example.com', 'me @example.com', '@example.com', 'x'.repeat(310) + '@example.com'] + +describe('sync setting rules shared by the form and the Host', () => { + it('take the same branch names, directories and author addresses on both sides', () => { + for (const name of BRANCHES) expect(branchValid(name), name).toBe(takes(syncBranch, name)) + expect(BRANCHES.filter(branchValid)).toEqual(['mnemon-sync', 'user/mnemon-sync', 'v1.2', 'a', ' padded ', 'x'.repeat(200)]) + for (const directory of DIRECTORIES) expect(directoryValid(directory), directory).toBe(takes(syncSubdirectory, directory)) + expect(DIRECTORIES.filter(directoryValid)).toEqual(['mnemon', 'mnemon/', '/nested/memory/', 'a\\b', ' a ', 'x'.repeat(400)]) + // An empty author address means this machine's own Git identity, which the form offers as a choice of its own. + for (const address of ADDRESSES) expect(emailValid(address), address).toBe(takes(syncAuthorEmail, address)) + expect(syncAuthorEmail('')).toBe('') + }) + + it('check a repository address alike, except a local path, which only the Host can check by its platform', () => { + const remote = { + 'https://github.com/example/memory.git': undefined, 'ssh://git@github.com:22/example/memory.git': undefined, 'git@github.com:example/memory.git': undefined, + 'https://user:secret@github.com/example/memory.git': 'credentials', 'https://token@github.com/example/memory.git': 'credentials', + 'ssh://user@github.com/example/memory.git': undefined, 'ftp://host/repo': 'format', 'file:///srv/memory.git': 'format', + 'https://github.com': 'format', 'github.com/example/memory': 'format', 'https://github.com/example/memory.git extra': 'format', ['https://x/' + 'y'.repeat(2000)]: 'format', + } as const + for (const [address, problem] of Object.entries(remote)) { + expect(repositoryProblem(address), address).toBe(problem) + expect(syncRepositoryProblem(address, posix.isAbsolute), address).toBe(problem) + expect(syncRepositoryProblem(address, win32.isAbsolute), address).toBe(problem) + } + // The form takes a path that is absolute on either platform; the Host takes one by its own. + const local = [ + ['/srv/memory.git', true, true, true], ['C:\\memory\\repo.git', true, false, true], ['C:/memory/repo.git', true, false, true], + ['\\\\server\\share\\repo.git', true, false, true], ['//server/share/repo.git', true, true, true], + // A Windows path from the root of the current drive: absolute to Node on Windows, not to the form. + ['\\memory\\repo.git', false, false, true], ['C:repo.git', false, false, false], ['relative/path', false, false, false], ['~/memory.git', false, false, false], + ] as const + for (const [path, form, onPosix, onWindows] of local) { + expect(portableAbsolutePath(path), path).toBe(form) + expect(repositoryProblem(path), path).toBe(form ? undefined : 'format') + expect(syncRepositoryProblem(path, posix.isAbsolute), path).toBe(onPosix ? undefined : 'format') + expect(syncRepositoryProblem(path, win32.isAbsolute), path).toBe(onWindows ? undefined : 'format') + } + // An empty address clears the repository in the form; the Host never stores one. + expect(repositoryProblem(' ')).toBeUndefined() + expect(() => syncRepositoryUrl(' ')).toThrow('sync repository must be an https:// or ssh:// URL') + }) + + it('leave refusing a NUL byte to the Host, with the messages it gives', () => { + expect(repositoryProblem('/srv/memory\0.git')).toBeUndefined() + expect(() => syncRepositoryUrl('/srv/memory\0.git')).toThrow('sync repository must be an https:// or ssh:// URL, a git@host:path address, or an absolute local path') + expect(() => syncRepositoryUrl('https://user@github.com/memory\0.git')).toThrow('sync repository must be an https:// or ssh:// URL') + expect(directoryValid('mnemon\0')).toBe(true) + expect(() => syncSubdirectory('mnemon\0')).toThrow('sync directory must be a relative path inside the branch') + }) + + it('name a machine and a GitHub repository by one rule on both sides', async () => { + const names = ['Studio', ' Studio ', '', ' ', 'two\nlines', 'tab\tbed', 'line\u2028separated', 'x'.repeat(MNEMON_MACHINE_LABEL_MAX), 'x'.repeat(MNEMON_MACHINE_LABEL_MAX + 1)] + for (const name of names) expect(machineNameValid(name), name).toBe(takes(machineLabel, name)) + expect(names.filter(machineNameValid)).toEqual(['Studio', ' Studio ', 'x'.repeat(MNEMON_MACHINE_LABEL_MAX)]) + // GitHub sign-in checks the name before it asks GitHub, which answers here with a created repository. + const grant = { version: 1 as const, accessToken: 'gho_rules', savedAt: 'now' } + const port = { available: () => true, describe: async () => ({ configured: true, writable: true }), read: async () => grant, write: async () => {}, clear: async () => {} } + const created = { name: 'memory', full_name: 'octocat/memory', clone_url: 'https://github.com/octocat/memory.git', private: true, default_branch: 'main', owner: { login: 'octocat' }, permissions: { push: true } } + const request = (async () => new Response(JSON.stringify(created), { status: 201, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch + const github = new MnemonGitHubAuth(port, MNEMON_GITHUB_DEFAULT_CLIENT_ID, () => 0, request) + const repositories = ['mnemon-memory', ' memory ', 'a.b_c-d', '.', '..', '...', 'a/b', 'has space', '', 'x'.repeat(100), 'x'.repeat(101)] + for (const name of repositories) { + const accepted = await github.create(name, true).then(() => true, (error: Error) => { + expect(error.message).toBe('a GitHub repository name holds letters, digits, dots, dashes and underscores') + return false + }) + expect(repositoryNameValid(name), name).toBe(accepted) + } + expect(repositories.filter(repositoryNameValid)).toEqual(['mnemon-memory', ' memory ', 'a.b_c-d', '...', 'x'.repeat(100)]) + }) +})