# dsh-hub — Design Contract Authoritative for every implementer. Read this file first; it outranks any restatement of the requirement. Its two owners are fixed here so no fact lives in two places: | Concern | Sole owner | |---|---| | Durable bytes, validation, atomic writes | Host (`lib/`, `host.js`) | | Search / filter / sort / group / grouping math | Client (`client.js` only) | Nothing is mirrored between them. If the Client needs a value the Host does not send, the Host sends it — the Host never re-implements a query. ## 1. Package `@hanmaxiao/dsh-hub`, MIT, ESM, no build step, no TypeScript. `package.json`, `cordis.patch.yml` and `icon.svg` are already written and are **owned by the Lead** — read them, do not edit them. Installed by one of four specs, all of which `plugin_manager` and the Plugins page accept: a registry name, a git address, a tarball, or an absolute path. **Renaming the package is a breaking install change** — the name is the client manifest key, the Typert invocation id and the profile dependency, so a rename means every installed profile reinstalls. ## 2. Seats - `shell.overlay` (list, root scope, `replaceRisk: none`) — the docked panel. Register with a **fresh** id: `hub.panel`. Reusing a shipped id replaces that cell. The layer is click-through; the entry opts back into `pointer-events`. **This is the panel's only seat.** - **Do not register `sidebar.panellist`.** Every row of that slot is a `layout.selectPanel(id)` route — `SidebarRoot` calls it unconditionally on click, and `LayoutController.selectPanel` throws for any id with no `main` keyed entry (`ui-layout/src/client/service.ts`). A panel that lives in `shell.overlay` has no main panel, so a row registered there is a crash-on-click button; absorbing the throw with a null-rendering `main` entry only trades the crash for a visible one-frame flash. §9's collapsed handle is the intended affordance and is always on screen, so the sidebar icon is omitted rather than worked around. - **Two different `inject` fields — do not confuse them.** `package.json`'s `dsh.client.inject` lists *package names* and only orders module arrival; it belongs to the Lead's manifest and must not be touched. The client factory's returned `inject: ['slots', 'locale']` lists *Cordis services* and is what parks a fiber forever when the composition cannot satisfy it — so `remote` must never appear there. Read it with `ctx.get('remote')` and degrade when it is absent. ## 3. Host ↔ Client bridge `host.call` and `styles.insert` are `cordis_run`-runner builtins, **not** available to a `dsh.client` bundle. The only bridge is a **Typert Remote**: - `remote.js` exports `HubRemote extends TypertRemoteService`, `SERVICE_KEY = 'hub'`. `@Remote` markers are installed by hand through the protocol's public API — copy the `installRemoteMarker` / `installRemoteMarkers` technique from any hand-written Remote in the wild (this one was written against `dsh-prompt-optimize`'s `remote.js`) verbatim in shape, on a hand-built `{ private, static, name, addInitializer }` context. - `HubRemote` declares `static inject = ['hub-store']`, and that is load bearing rather than decorative: `ctx.provide()` registers inside `ctx.fiber.effect()`, and `ctx.get()` reports a service whose fiber is not yet ACTIVE as absent, so a `HubRemote` started in the same `apply` as the provide reads nothing and throws in its constructor — while the row stays ACTIVE and the only symptom is `gateway/service-unavailable` at the first call. Registration order is not a substitute for the declaration. - `host.js` writes its receipt after the Remote fiber settles and reads the logger with `ctx.get('logger')`: Cordis throws on a service property read a plugin did not declare in `inject`, and a throw inside `ctx.effect` is swallowed. - `typert.js` is the matching manifest: `package` must equal the package name, and `invocations` must describe **exactly** the methods `remote.js` marks. The pass-through `codec` shape from `dsh-prompt-optimize/typert.js` is the proven zero-zod form; reuse it. - **The gateway dispatches arguments positionally**, in the manifest's `parameters` order (`gateway/.../access.ts`), and `cancellation.parameter` must be declared **last** — it is appended after the declared parameters rather than being one of them. A manifest whose order disagrees with the service signature shifts every argument silently instead of failing. Keep the two files adjacent in review for that reason. - **The Client mounts its own contribution.** `dsh-typert-loader` discovers a package's `./typert` export on the **Host** only, and the client-side mounts shipped in the base are the generated first-party ones inside `@deepseek-ai/dsh-api-remotes`. Nothing mounts a third-party package's namespace for it, so `client.js` calls `ctx.remote.$mount()` itself with a contribution mirroring `typert.js`. This is the technique `@deepseek-ai/dsh-experimental-client-ui-voice-input` uses for its `speech` namespace, and it must be awaited before the seat is registered so the first `list()` never races the namespace into existence. - **The namespace is its own service, not a property of `remote`.** `$mount()` installs each namespace as a traced Cordis service under `remote.`, so the panel reads `ctx.get('remote.hub')`. The `remote` carrier itself only ever carries `$mount`, `$stream`, `$host` and `$on`; `ctx.get('remote')?.hub` is always undefined. A refused mount is remembered and shown, never thrown out of `apply`, so the panel stays visible and reports the real reason. - **A published name may not shadow the namespace service.** `RemoteNamespaceService.assertMethodAvailable` refuses a method that is one of `ctx`, `empty`, `invokeRemote`, `methods`, `name`, `namespace`, or that already exists on that class's prototype — which includes its own `remove(kind, method, token)` helper. So the delete is published as **`delete`** with `implementation: 'remove'`: the gateway dispatches through `descriptor.implementation ?? descriptor.method`, so the Host keeps `remove`. - **A mounted call answers with an envelope.** Every method returns `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and does not throw a business failure, so `hubCall` unwraps it and throws what the Host rejected with. Only an assembly fault rejects. ## 4. Persistence - `dataDir` Config field (already in `cordis.patch.yml` as `''`). Empty string resolves to `$DSH_HOME/plugin-data/dsh-hub`, falling back to `~/.dsh/plugin-data/dsh-hub` when `DSH_HOME` is unset. A non-empty value is `path.resolve`d. - One JSON document, `/store.json`. Write via temp file + rename so a crash mid-write cannot truncate the store. - Schema version field. Read a missing file as an empty store; read an unreadable or malformed one as a **loud failure** naming the path — never silently reset the user's data. - Images and files are stored inline as base64 data URLs inside the document. This is a deliberate v1 simplification (one file, trivially backed up). - **`maxImageBytes` is a Config field** (already in `cordis.patch.yml`, default `2097152` = 2 MiB), measured on the **decoded** bytes before encoding. It is a real ceiling, not a courtesy: `list` reads the whole document, so the encoded size is what every panel open pays. base64 inflates by about a third, so 2 MiB decoded costs roughly 2.7 MiB in the store. The Host must reject an oversized image with a message naming both the limit and the actual size. ## 5. Data model ``` TODO { id, kind:'todo', title, description, category, priority, done, attachments:[{name,mime,size,dataUrl}], note, createdAt, updatedAt } NOTE { id, kind:'note', title, text, images:[{name,mime,size,dataUrl}], createdAt, updatedAt } PROMPT { id, kind:'prompt',title, text, images:[{name,mime,size,dataUrl}], createdAt, updatedAt } ``` `priority` is one of `low | normal | high | urgent`. `done` is a boolean. **`title` is optional and stored, not derived.** It is trimmed, and an absent or blank one is stored as `''` — a document written before the field existed reads as an untitled item rather than as corruption, so adding it is additive and needs **no schema bump**. `title` is content: a patch may set it, and omitting it leaves the stored title alone. The display name is a *derived* view and stays in the Client (see §6): it is the title when there is one, otherwise the first line of the content. So an untitled item still reads well, and the panel never forces a title on anyone. `create` **carries `kind` explicitly** — the Client always knows which tab the draft came from, so there is nothing for the Host to infer, and a Host-side guess would have to be a heuristic over field shapes. The Host fills `id`, `createdAt` and `updatedAt`. `update` carries no `kind`: it is refused there. The Host validates every write and rejects unknown `kind` or `priority` values with an actionable message naming the offending field. Fields outside this model are dropped rather than rejected; `kind` and `priority` out of range are the two rejections that fire on a well-formed-looking payload, because neither has a defensible default. ## 6. Sort — from 图一 Obsidian's sort menu: **one field + one direction + optional grouping**, each radio-selected. It is *not* a comparator chain. **Fields.** TODO: `名称` `修改日期` `类型`(→category) `大小` `创建日期` `标记`(→priority). Note/Prompt: `名称` `修改日期` `大小` `创建日期`. `名称` is the **derived** display name: the item's `title` when it has one, otherwise the first line of its content. Sorting by it therefore sorts by the title the user typed, and still orders an untitled item by what it says. Obsidian's `作者` is **dropped** — no equivalent in this model. Do not render an empty entry for it. Obsidian's `标题` is **no longer dropped**: it used to be the same thing as `名称`, because `名称` was itself the first line of the body. Now that a title is stored and editable (§5), the two are different facts — the field the user edits, and the name derived for display — so the menu keeps `名称` and the form carries `标题`. **Direction.** `递增` / `递减`, ascending = name A→Z, date old→new, size small→large, priority low→high. Descending inverts. **Grouping.** `分组依据`: TODO → `无 / 类别 / 优先级 / 完成状态`; Note & Prompt → `无 / 含图片 / 纯文字`. **Tiebreak.** Every comparator ends with `createdAt` then `id` ascending, so equal keys never reorder between renders. **Size** is computed from the item's own bytes — title, content, note and attachment bytes — and is computed in `client.js`; the Host does not store a denormalised size. The title counts because it is stored bytes like any other. ## 7. Client module — ONE file `manifest.ts` states "Entry name == package name" and `exactPackageSpecifier` accepts only a whole package name, so a `dsh.client` bundle **cannot** be split across files. `client.js` is one file organised under banner comments: 1. locale dictionaries (zh + en) 2. SVG icon set — **no emoji anywhere** 3. theme-token stylesheet, rendered as a React `