@coreness/dsh 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -2
- package/bundles/coreness.patch.yml +127 -0
- package/package.json +62 -4
- package/plugins/journal/README.md +116 -0
- package/plugins/journal/domain.js +165 -0
- package/plugins/journal/fingerprint.js +157 -0
- package/plugins/journal/index.js +169 -0
- package/plugins/journal/journal.js +289 -0
- package/plugins/journal/rotation.js +63 -0
- package/plugins/journal/summary.js +132 -0
- package/plugins/journal/tools.js +166 -0
- package/plugins/mcp/README.md +190 -0
- package/plugins/mcp/index.js +220 -0
- package/plugins/mcp/picture.js +139 -0
- package/plugins/mcp/row.js +515 -0
- package/plugins/mcp/tree.js +128 -0
- package/plugins/mcp/trouble.js +114 -0
- package/plugins/mcp/write.js +284 -0
- package/plugins/page/README.md +111 -0
- package/plugins/page/client.credits.js +617 -0
- package/plugins/page/client.gate.js +1021 -0
- package/plugins/page/client.hold.js +41 -0
- package/plugins/page/client.js +521 -0
- package/plugins/page/client.mcp.js +1453 -0
- package/plugins/page/client.picker.js +235 -0
- package/plugins/page/client.provider.js +2002 -0
- package/plugins/page/index.js +6 -0
- package/plugins/provider/qoder/README.md +346 -0
- package/plugins/provider/qoder/account.js +187 -0
- package/plugins/provider/qoder/adapter.js +774 -0
- package/plugins/provider/qoder/body.js +584 -0
- package/plugins/provider/qoder/credits.js +397 -0
- package/plugins/provider/qoder/frames.js +415 -0
- package/plugins/provider/qoder/images.js +97 -0
- package/plugins/provider/qoder/in.js +1142 -0
- package/plugins/provider/qoder/index.js +692 -0
- package/plugins/provider/qoder/markup.js +414 -0
- package/plugins/provider/qoder/probe.js +143 -0
- package/plugins/provider/qoder/sign.js +246 -0
- package/plugins/provider/qoder/spend.js +142 -0
- package/plugins/rate-gate/README.md +179 -0
- package/plugins/rate-gate/check.mjs +11 -0
- package/plugins/rate-gate/gate.js +765 -0
- package/plugins/rate-gate/index.js +539 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vensus137
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Coreness for DeepSeek Harness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A kit of plugins a DeepSeek Harness profile works by: a route to the Qoder models, the journal the environment and its participants write, the picture and the form of the profile's MCP servers, the rate gate that holds a request rather than refusing it and limits nobody as it ships, and a page of the product's own that draws the overview and the settings.
|
|
4
|
+
|
|
5
|
+
The whole of it is one package, `@coreness/dsh`: the manifest at the root of this repository, the plugin folders beside it, and `bundles/coreness.patch.yml` — the patch that names every row the package mounts.
|
|
6
|
+
|
|
7
|
+
## What it needs
|
|
8
|
+
|
|
9
|
+
- **A DeepSeek Harness runtime of the version this package was built against — `0.2.0-rc.2`.** `peerDependencies` declares it, and the platform compares that declaration with its own version before a profile imports a plugin: a mismatch leaves the plugin out and reports the package as incompatible instead of failing quietly later.
|
|
10
|
+
- **A Qoder account and its personal access token.** The token is not a line of a command and not a file: it is written once through the product's own page — the Qoder section of the Coreness page — into the platform's credential store, under the name the row declares (`apiKeyEnv: QODER_PERSONAL_ACCESS_TOKEN` in `bundles/coreness.patch.yml`). The platform's own Settings → Models shows the row and cannot fill it; no secret stands in the package, in the patch or in a log.
|
|
11
|
+
- **Nothing to build.** The client halves are plain `.js` files served as they lie, so what is packed is what runs.
|
|
12
|
+
|
|
13
|
+
## How to install
|
|
14
|
+
|
|
15
|
+
Two acts of the platform's launcher — an install, then a boot:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
npm pack # writes coreness-dsh-<version>.tgz
|
|
19
|
+
dsh plugin --profile web add ./coreness-dsh-*.tgz
|
|
20
|
+
dsh web
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The install is the whole of the setup, and it takes the same four forms the application's own **Plugins** page takes in its *Add plugin* dialog: a tarball as above, the package's registry name once it is published, a git address, or an absolute local path. A profile of that name does not exist on a fresh machine, and the platform creates it there from its own shipped `web` template — the base bundle and the web application — before installing this package into it. A profile named otherwise is created from the base bundle alone and carries no interface, which is why the shipped template's own name stands in the line.
|
|
24
|
+
|
|
25
|
+
The boot is its own act because the install door only runs the package manager and exits; opening the application is the same act by another hand. The composition of a profile is read when the application starts, so the package appears after a restart of it, while a change of a plugin's code is visible without one. Where the installation does not put `dsh` on `PATH`, the launcher lies in the installation under `resources/runtime/cli/bin/`; the application's own **Plugins** page takes the same four forms in its *Add plugin* dialog and needs no terminal at all.
|
|
26
|
+
|
|
27
|
+
## What it does not do
|
|
28
|
+
|
|
29
|
+
- **It does not manage the platform's plugins.** The product's page counts and reads; the platform's own Plugins page is what switches.
|
|
30
|
+
- **It does not keep an MCP client, a connection state or a registry of its own.** The platform owns the servers; the product owns the picture and the form, writes a row through the Loader, and reads the result back rather than calling a write saved.
|
|
31
|
+
- **It does not see the MCP servers a session declares for itself**: those live in that session and never in the profile's file.
|
|
32
|
+
- **It does not count the price of a task.**
|
|
33
|
+
- **It does not draw a statistics view over the journal**: the journal is the agents' tool and shows nothing on the page.
|
|
34
|
+
- **It does not hold the token**: the value lies in the platform's store, and the page reads its state back rather than keeping a notion of its own.
|
|
35
|
+
|
|
36
|
+
## Rules of work
|
|
37
|
+
|
|
38
|
+
The conventions of work in this repository are a snapshot of the [Coreness Kit](https://github.com/Vensus137/Coreness-Kit): the file `AGENTS.md`, in full, which is not edited here. The picture of the project is `PROJECT.md`, read first by whoever enters the work; `NOTES.md` stands beside it with what is noticed and not settled.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# The product's rows: one row per plugin of this package.
|
|
2
|
+
#
|
|
3
|
+
# The rows live in the bundle rather than beside each plugin: a bundle names its own
|
|
4
|
+
# patch files relative to its package root (`dsh.bundle.patch` in package.json), and
|
|
5
|
+
# the rows of the product are one assembly — a reader looking for what the product
|
|
6
|
+
# mounts reads this file, not a file per plugin. The plugins keep their own code,
|
|
7
|
+
# tests and READMEs; a patch of their own would make each of them look like a bundle
|
|
8
|
+
# and would give the same rows two possible owners.
|
|
9
|
+
#
|
|
10
|
+
# `id` is the plugin's name in cordis, `name` is the package specifier the Loader
|
|
11
|
+
# resolves: the root of the package for the page (its browser half is the package's
|
|
12
|
+
# own `./client`, and one package carries one browser half), and a subpath for the
|
|
13
|
+
# journal (host-only — a subpath row carries no browser half, which is exactly what
|
|
14
|
+
# it needs).
|
|
15
|
+
- insert:
|
|
16
|
+
- id: coreness-page
|
|
17
|
+
name: '@coreness/dsh'
|
|
18
|
+
|
|
19
|
+
# The MCP plugin and the file its rows live in.
|
|
20
|
+
#
|
|
21
|
+
# The plugin is host-only: it reads the profile's servers and writes them through
|
|
22
|
+
# the Loader, and the page reaches it by its own route. The second row is the
|
|
23
|
+
# platform's own `cordis:include`, mounted here for the same reason the donor
|
|
24
|
+
# mounted it — a row of a composition is written by the include that owns its
|
|
25
|
+
# file, and a file of its own is what keeps the servers from having a second
|
|
26
|
+
# writer in the profile's patch.
|
|
27
|
+
#
|
|
28
|
+
# The path is relative to the *profile's* directory: every patch row is applied
|
|
29
|
+
# inside the composition the profile's own config file mounts, so `./mcp-servers.yml`
|
|
30
|
+
# is `$DSH_HOME/profiles/<name>/mcp-servers.yml`. The id here is the one the plugin
|
|
31
|
+
# looks for (`INCLUDE_ID` in `plugins/mcp/index.js`), and `initial: []` lets a fresh
|
|
32
|
+
# profile start with an empty list instead of no file at all.
|
|
33
|
+
- id: coreness-mcp
|
|
34
|
+
name: '@coreness/dsh/plugins/mcp'
|
|
35
|
+
|
|
36
|
+
- id: mcp-servers
|
|
37
|
+
name: cordis:include
|
|
38
|
+
config:
|
|
39
|
+
path: ./mcp-servers.yml
|
|
40
|
+
initial: []
|
|
41
|
+
|
|
42
|
+
# The journal's row: the limits of its rotation and the limit of its selection.
|
|
43
|
+
# The donor's price setting does not come over — the product does not count the
|
|
44
|
+
# price of a task (the user's decision of 06.10).
|
|
45
|
+
- id: coreness-journal
|
|
46
|
+
name: '@coreness/dsh/plugins/journal'
|
|
47
|
+
config:
|
|
48
|
+
rotationDays: 30
|
|
49
|
+
rotationBytes: 2097152
|
|
50
|
+
recordsLimit: 20
|
|
51
|
+
|
|
52
|
+
# The rate gate's row: host-only, like the journal's — it stands at the platform's `llm/stream`
|
|
53
|
+
# waterfall and has no browser half.
|
|
54
|
+
#
|
|
55
|
+
# What this row declares is the *shape* of the gate and not a ceiling: a window of one minute, a
|
|
56
|
+
# wait bound of the same minute, a caution ratio of nine tenths, and no watching. **No row under
|
|
57
|
+
# `limits` names a model** — so the gate mounts, says so in the log, and lets every request
|
|
58
|
+
# through untouched. That is deliberate, and it is the project's rule (PROJECT.md, "a default is
|
|
59
|
+
# not a declaration"): what the product has in mind is written explicitly, while a ceiling is a
|
|
60
|
+
# decision of a profile and not of the bundle. A profile that wants one adds rows under `limits`
|
|
61
|
+
# — one per model, with the ceiling in tokens per window — and the gate then *holds* a request
|
|
62
|
+
# whose window is nearly full instead of refusing it, releasing it when the window moves on or
|
|
63
|
+
# when the wait bound is spent (a request released that way is marked `forced`). The live run,
|
|
64
|
+
# the arithmetic of the window and the two defects it exposed are in the plugin's README.
|
|
65
|
+
- id: coreness-rate-gate
|
|
66
|
+
name: '@coreness/dsh/plugins/rate-gate'
|
|
67
|
+
config:
|
|
68
|
+
windowMs: 60000
|
|
69
|
+
maxWaitMs: 60000
|
|
70
|
+
thresholdRatio: 0.9
|
|
71
|
+
dryRun: false
|
|
72
|
+
|
|
73
|
+
# The Qoder route's row: host-only, like the journal's — the wire is the host side of the route
|
|
74
|
+
# and has no browser half. Its settings fall in two groups.
|
|
75
|
+
#
|
|
76
|
+
# The route's own facts: the *name* of the credential the token is stored under (the value lives
|
|
77
|
+
# in the platform's own store — `ctx.credentials` — so no secret lies in this file, in a log or in
|
|
78
|
+
# the package), the effort the route asks the vendor for, and the three ceilings of the transport,
|
|
79
|
+
# whose reasons and numbers are in the plugin's README.
|
|
80
|
+
#
|
|
81
|
+
# The route's declaration to the platform — the models it advertises — stands in the row schema's
|
|
82
|
+
# own default and is **not** restated here: one list has one owner (`plugins/provider/qoder/index.js`,
|
|
83
|
+
# `DEFAULT_MODELS`, five rows with `effort: 'max'`), and that is what a profile with no patch resolves
|
|
84
|
+
# to. A copy in this file would beat the schema on every `Reset to defaults`, because the patch layers
|
|
85
|
+
# are applied one over another. The windows, the measurement behind them and what would move them live
|
|
86
|
+
# with that list and in the plugin's README.
|
|
87
|
+
#
|
|
88
|
+
# The policy is the platform's own shape and its own defaults, with one addition:
|
|
89
|
+
# `MALFORMED_RESPONSE` joins the five retryable codes. One tool call whose arguments do not parse
|
|
90
|
+
# ended whole agent sessions in this project — how many, and the turns that say so, stand in the
|
|
91
|
+
# sessions' own records, not here. Such an attempt carries nothing durable to repeat and a
|
|
92
|
+
# re-ask is a fresh sample — so the step is re-asked instead of the session ending. The list stands
|
|
93
|
+
# here, not in the code, so that it can be narrowed from the row without a code change.
|
|
94
|
+
- id: coreness-qoder
|
|
95
|
+
name: '@coreness/dsh/plugins/provider/qoder'
|
|
96
|
+
config:
|
|
97
|
+
apiKeyEnv: QODER_PERSONAL_ACCESS_TOKEN
|
|
98
|
+
retryPolicy:
|
|
99
|
+
mode: normal
|
|
100
|
+
maxRetries: 5
|
|
101
|
+
retryableCodes:
|
|
102
|
+
- EMPTY_RESPONSE
|
|
103
|
+
- RATE_LIMIT
|
|
104
|
+
- SERVER
|
|
105
|
+
- TIMEOUT
|
|
106
|
+
- TRANSPORT
|
|
107
|
+
- MALFORMED_RESPONSE
|
|
108
|
+
backoff:
|
|
109
|
+
initialDelayMs: 500
|
|
110
|
+
maxDelayMs: 10000
|
|
111
|
+
jitterRatio: 0.1
|
|
112
|
+
|
|
113
|
+
# The account's credits, as the product's own widget reads them: how often the sweep reads the
|
|
114
|
+
# vendor's account, and whether that sweep takes what is claimable before it expires.
|
|
115
|
+
#
|
|
116
|
+
# Both numbers are ours, not the donor's. The donor swept every five minutes and claimed
|
|
117
|
+
# automatically; what those five minutes bought was a figure almost live — and what they cost
|
|
118
|
+
# was a write into the profile's patch file on every tick. This widget's figures come over a
|
|
119
|
+
# route and write no
|
|
120
|
+
# file, so the cadence buys only freshness and the collection's own timing: the vendor's reward
|
|
121
|
+
# window opens once a day, so ten minutes keeps the figure as useful as five would at half the
|
|
122
|
+
# requests. The reason in words lives beside the number in the plugin
|
|
123
|
+
# (plugins/provider/qoder/account.js), and a row outside the plugin's bounds is told so in the
|
|
124
|
+
# log and given the nearest honest value instead of being obeyed or refused.
|
|
125
|
+
credits:
|
|
126
|
+
sweepMs: 600000
|
|
127
|
+
autoClaim: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coreness/dsh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Coreness kit for DeepSeek Harness: a page of its own, the journal, the picture and the form of the profile's MCP servers, and the Qoder route — one bundle for a profile.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"cordis",
|
|
9
|
+
"plugin-bundle",
|
|
10
|
+
"profile",
|
|
11
|
+
"mcp",
|
|
12
|
+
"journal",
|
|
13
|
+
"qoder"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Vensus137 <vensus137@yandex.ru>",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./plugins/page/index.js",
|
|
23
|
+
"./client": "./plugins/page/client.js",
|
|
24
|
+
"./plugins/journal": "./plugins/journal/index.js",
|
|
25
|
+
"./plugins/mcp": "./plugins/mcp/index.js",
|
|
26
|
+
"./plugins/rate-gate": "./plugins/rate-gate/index.js",
|
|
27
|
+
"./plugins/provider/qoder": "./plugins/provider/qoder/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bundles/",
|
|
31
|
+
"plugins/",
|
|
32
|
+
"!plugins/**/test/"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@deepseek-ai/schemastery": "~3.18.4",
|
|
36
|
+
"zod": "^4.4.3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@deepseek-ai/cordis": "~4.0.4",
|
|
40
|
+
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.2",
|
|
41
|
+
"@deepseek-ai/dsh-plugin-manager": "0.2.0-rc.2",
|
|
42
|
+
"@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2",
|
|
43
|
+
"@deepseek-ai/dsh-timeout": "0.2.0-rc.2",
|
|
44
|
+
"@deepseek-ai/dsh-tools": "0.2.0-rc.2"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "~4.0.4",
|
|
48
|
+
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.2",
|
|
49
|
+
"@deepseek-ai/dsh-plugin-manager": "0.2.0-rc.2",
|
|
50
|
+
"@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-timeout": "0.2.0-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-tools": "0.2.0-rc.2"
|
|
53
|
+
},
|
|
54
|
+
"dsh": {
|
|
55
|
+
"bundle": { "patch": "./bundles/coreness.patch.yml" },
|
|
56
|
+
"client": {
|
|
57
|
+
"platform": "web",
|
|
58
|
+
"inject": [
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# The journal
|
|
2
|
+
|
|
3
|
+
The environment's memory: what changed in the profile and what a participant did, kept as records in a storage domain, written by agents through two tools and answered through one exact route. A record carries its kind, its area, its name and its author — `среда` for what the environment observes itself, `участник` for what the user or an agent writes — and the fields of its kind. The environment observes its own life, the composition of the profile against the previous print and the rotation of its own records, whose baseline print is never dropped; a participant writes the four other kinds, a refusal among them carrying its reason. The two tools are `journal`, a query — a summary without a kind, the records themselves with one — and `journal_note` for what cannot be observed: a closed task, a step with its duration, a refusal with its reason, a handover. The plugin is host-only: its row names a subpath of the package, and a subpath row carries no browser half. Nothing of it is shown in the interface, and that is a decision, not a gap.
|
|
4
|
+
|
|
5
|
+
## Dependencies
|
|
6
|
+
|
|
7
|
+
- **The platform's storage stack**: `storageDomain` over `@deepseek-ai/dsh-storage-json`, whose root the base bundle sets to `dshHomePath('storages')`, with `backend: json` — plus `zod`, whose schema the domain facility applies to every stored record on open. The domain `coreness_journal` (version 1) keeps one table, `records`, keyed by the address `time|kind|area|id`, in the domain's default layout: one file, `$DSH_HOME/storages/coreness_journal.json`. Rotation is by age and by size, counted from the records on every write, and the last record carrying a composition print is never dropped: it is the baseline of the next comparison, and losing it would read as a false "composition changed" at the next start — the baseline stays even when it is the only record (`rotation.js`). The domain's name keeps an underscore while the row's id keeps its hyphen, because the two are different places: one is a storage domain, the other an entry of the patch.
|
|
8
|
+
- **One storage root for the whole home, not for one profile.** The base bundle points the storages at `dshHomePath('storages')`, so every profile of a home meets the same file and a run of one writes the memory the other reads. The dev profile of this repository therefore carries a redirect in its own patch — `root: !!js dshHomePath('profiles/coreness-dev/storages')` — and a run of it writes nothing the live profile reads. Any other run keeps a store to itself the same way — the recipe is in `## Check`.
|
|
9
|
+
- **The donor's file is read as it is.** The donor journal wrote the same unit with one field more on its tasks; such a record is read, the field rides by and is neither interpreted nor lost.
|
|
10
|
+
- **`tools`**: `defineTool` from `@deepseek-ai/dsh-tools` compiles the argument schema of the two tools and validates what a model sends, so a refusal of a wrong argument is the platform's own.
|
|
11
|
+
- **`connection`**: `ctx.connection.fetch.register` — the door the plugin opens outward. It is the route rather than the platform's Remote (Typert) door, and the reason is measured rather than preferred: a Remote needs a generated descriptor on each face, and nothing in the installed application regenerates one — the archive ships the loader, the registry and the protocol, while the generator the registry's own documentation points at is not there — and the client assembly mounts a fixed list of build-time imports, so a capability of ours would need an edit of a platform package (`a:dsh-api-remotes/README.md:75-76`, `a:dsh-typert-loader/README.md:69`; the validation rules of that door were read, not exercised). The route needs nothing generated, which is why the platform's own four packages read their page's data through it (`a:dsh-client-ui-deliverables/lib/index.js:48-99`, `a:dsh-client-file-upload/lib/index.js:171`, `a:dsh-api-session-controller/lib/index.js:2396`, `a:dsh-session-log-export/lib/index.js:516`), and why the donor journal of another tree — the closest precedent to a page of this product — named its route as an exported constant and registered it inside an effect with a disposer, answering with a `Response` whose refusal is data with words.
|
|
12
|
+
- **What the browser side of that door is.** The path is absolute under `/api` on the host and document-relative in the browser; the connection service owns the `/api` route, the fetch bridge and the browser's authentication as a whole, and a path nobody claimed answers 404. Authentication is the page's own cookie: the launch token is exchanged at `GET /` for an authority-bound signed cookie, and a request without it is refused before dispatch — there is no method-specific loopback tier and no authorization header, and the body handling of a route is declared before a byte of it is read (`a:dsh-client-connection/README.md:39`, `:43`). A path no owner claimed is answered 404 by the handler that dispatches the shared `/api` route (`a:dsh-api-gateway/README.md:31`).
|
|
13
|
+
- **No push of our own, and what follows from it.** The legal key set of `ctx.remote.$on` is the application assembly's forwarded-event list, so a new key means editing a platform package, and ordinary one-way notifications are best-effort deliveries that are not replayed (`a:dsh-api-remotes/README.md:75-77`). A journal that must survive a re-connect is therefore read rather than pushed: a reader asks again on its own demand, and the page's remote service emits `connection/reset` when it re-connects.
|
|
14
|
+
- **`pluginManager`** for the bundles and the rows of the composition print and for `plugin-manager/changed`, and **`profileContext`** for the profile's directory and its files. The print covers the profile's own `package.json`, `cordis.patch.yml` and `compatibility.json` together with the live composition as the manager sees it, and it compares a row by its module and its enablement rather than by the Loader's address — that address is random where a row was declared without a name, so a comparison by address would record an edit that never happened (`fingerprint.js`). The baseline of a comparison is the last record carrying a print rather than the previous start, because an edit of a profile file by hand is announced by nobody.
|
|
15
|
+
- **`@deepseek-ai/schemastery`** for the row's config.
|
|
16
|
+
- **Declared as peers.** The manifest names `@deepseek-ai/cordis` and five packages in `peerDependencies`, the way the platform's own packages do: the four providing these services (`dsh-client-connection`, `dsh-plugin-manager`, `dsh-storage-domain`, `dsh-tools`) and `dsh-timeout`, which the Qoder route of the same package imports and the journal itself does not. `profileContext` is not among them: the launcher provides it itself.
|
|
17
|
+
- **A missing neighbour is an expectation, not a stage error.** Without the storage stack the domain does not open: the plugin says so in its faults and answers the route with its refusal, rather than failing silently or pretending the journal is empty. Without the manager or the profile's directory the composition print is incomplete and names the part it does not know.
|
|
18
|
+
- **No browser half of its own.** The row of this plugin names a subpath of the package, and a subpath row carries no browser half at all: the package's one browser half belongs to the page's row.
|
|
19
|
+
|
|
20
|
+
## In the surface
|
|
21
|
+
|
|
22
|
+
The plugin draws nothing and takes no seat: it stands in the composition as one row of `bundles/coreness.patch.yml`, and what it opens is one exact route.
|
|
23
|
+
|
|
24
|
+
- **Nothing of the journal is shown in the interface — a decision, not a gap.** The journal is the agents' instrument: the environment's memory and the ground of reflection. The page carries what the user needs, so the journal has no section, no row and no counters there, and a surface added for its own sake would be an interface invented for an interface. The user's door to the profile's settings is the MCP face, and those settings are what a surface would show if one is ever wanted — a plugin of its own, on the mechanisms that already exist.
|
|
25
|
+
- **The row.** The id `coreness-journal`, the module `@coreness/dsh/plugins/journal`, and three settings: the two limits of rotation and the limit of a selection. The donor's price setting does not come over with it.
|
|
26
|
+
- **One exact route.** `GET /api/coreness.journal` on the platform's connection service, same origin, under the browser's own trust — the launch token exchanged for a cookie. It is the plugin's door outward: the one point through which a reader asks for the memory, and the door chosen because it needs nothing generated on either side, unlike the platform's Remote (Typert) mechanism. The records themselves are answered by the tool, not by the route.
|
|
27
|
+
- **How a reader reads it.** With a plain `fetch` on the document-relative path, no token in a header — the page's cookie rides along — and the answer decoded by the reader, which owns the shape of its own answer. The donor's own block read its route once on expansion and kept what it read; the same shape stands here: a state is painted from what the session already read and the read corrects it behind. A refusal is drawn as a state rather than as an empty page.
|
|
28
|
+
- **The shape of the answer.**
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{"ok": true, "value": {"summary": {
|
|
32
|
+
"window": {"since": null, "until": "2026-10-06T19:29:04.919Z"},
|
|
33
|
+
"total": 70, "bytes": 238826,
|
|
34
|
+
"byKind": [{"kind": "задача", "count": 19, "first": "…", "last": "…", "bytes": 19983}],
|
|
35
|
+
"last": [{"time": "…", "kind": "состав", "area": "профиль", "id": "база", "author": "среда",
|
|
36
|
+
"line": "changed: first print"}],
|
|
37
|
+
"refusals": [], "steps": {"count": 8, "ms": 4329304, "rereads": 1}}, "faults": []}}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A journal that is not open answers with its refusal in words rather than with an empty page:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{"ok": false, "error": {"code": "journal-unavailable", "message": "the journal is not open — …"}}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The status is `200` in both cases: the refusal is the answer's own content, and the reader draws it as a state rather than as an empty page. The answer leaves by the same guard as an answer of a tool — a value that does not survive the transfer is refused in words instead of losing its fields on the way.
|
|
47
|
+
|
|
48
|
+
## Changing this
|
|
49
|
+
|
|
50
|
+
One row per kind of change this plugin takes. The command is the file's own check; the whole set is `node --test "plugins/journal/test/*.test.mjs"` — 52 tests, under a second.
|
|
51
|
+
|
|
52
|
+
| the change | where it lands | what measures it |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| the vocabulary: the kinds, the record schema, the address that keys a record | `domain.js` | `test/domain.test.mjs` |
|
|
55
|
+
| what is written down about the composition, and how one print differs from another | `fingerprint.js` | `test/fingerprint.test.mjs` |
|
|
56
|
+
| what is dropped by age and by size | `rotation.js`, and the two limits in the row | `test/rotation.test.mjs` |
|
|
57
|
+
| the answer to "what happened and why it came to this" | `summary.js` | `test/summary.test.mjs` |
|
|
58
|
+
| what a participant may write, and which argument is refused | `tools.js` | `test/tools.test.mjs` |
|
|
59
|
+
| the store, the mount, the stop, and the observation the environment writes about itself | `journal.js` | `test/journal.test.mjs` |
|
|
60
|
+
| the row, its three settings, and the route outward | `index.js`, and the row in `bundles/coreness.patch.yml` | no test of its own: the route is read in a run — §Check, and the overlay that keeps a run out of the home's store |
|
|
61
|
+
| a ninth kind of record | `domain.js` — the vocabulary is closed on purpose, and the reader is told which unit was refused | `test/domain.test.mjs` |
|
|
62
|
+
| the door itself: which transport the route stands on, and what the browser side of it is | `index.js` — `ROUTE` and the `ctx.inject(['connection'])` area | the archive reads in §Check; the route is read in a run |
|
|
63
|
+
|
|
64
|
+
## Check
|
|
65
|
+
|
|
66
|
+
The plugin's own check drives the record schema, the rotation, the summary, the composition print, the stop and the tools through a stubbed domain and a stubbed table — the refusals in words, the baseline that survives rotation, and the answer that carries no price:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
node --test "plugins/journal/test/*.test.mjs" # tests 52, pass 52, fail 0
|
|
70
|
+
node scripts/accept.mjs # the repository's acceptance — all green
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The door's facts are read out of the installed application's archive by the repository's own reader, so a reader of this file does not have to trust it — the address form `a:<package>/<file>:<lines>` is the one the address checker reads, and the reader itself wants the path inside the archive:
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
node scripts/harness.mjs lines dsh/node_modules/@deepseek-ai/dsh-api-remotes/README.md 74 78 # the assembly's fixed list of build-time imports, and what is not replayed
|
|
77
|
+
node scripts/harness.mjs lines dsh/node_modules/@deepseek-ai/dsh-client-connection/README.md 36 43 # the browser session, the cookie, and there being no method-specific tier
|
|
78
|
+
node scripts/harness.mjs lines dsh/node_modules/@deepseek-ai/dsh-api-gateway/README.md 31 31 # 404 for a request no owner claimed
|
|
79
|
+
node scripts/harness.mjs lines dsh/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/index.js 15 18 # a route declared as an absolute constant under /api
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The installed assembly is read from the launcher, where a skipped bundle, a disabled row and a row that never mounts are visible:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
dsh --profile <name> --dump-config # - id: coreness-journal name: '@coreness/dsh/plugins/journal'
|
|
86
|
+
dsh --profile <name> --no-open # the boot log names no skipped bundle
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The route is checked by a run, because a store only appears in a run: the plugin records its own mount and compares the composition at start — a print lands when the composition has changed since the previous one — and the route answers the summary of what the store holds. On the dev profile's own store — `<home>/profiles/coreness-dev/storages/coreness_journal.json`, kept apart from the memory the live profile reads — a read gave `GET /api/coreness.journal` → `200`, `ok: true`, `total: 4` over three lifecycle records and one composition print, the newest of them the mount the boot itself wrote, and `faults: 0`. The answer's own shape carries no `price` field, no `credits` and no price line, and nothing was written while the profile stood: the store's newest record stayed the boot's own mount, and its time is the file's last write.
|
|
90
|
+
|
|
91
|
+
What a word in the answer does *not* mean: a record's `line` is quoted as it was written, so a record whose own text mentions a price — the journal holds a few, both of the donor era and of ours — carries that word in the answer. That is history, not a price line of the port: the whole answer's schema carries none. The check counts the port's own words and names the record when a word comes from a record.
|
|
92
|
+
|
|
93
|
+
A run that must not touch the home's own store — the home is shared with every profile — points the storages at a scratch folder through a patch overlay of its own, which changes no file of the profile:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
dsh --profile <name> --patch <overlay> --no-open --port <free port>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
# the overlay: the storages of this run, and of no other
|
|
101
|
+
- id: storage-json
|
|
102
|
+
config:
|
|
103
|
+
root: <a scratch folder>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Boundaries
|
|
107
|
+
|
|
108
|
+
- **The price does not come over.** No `price.js`, no `price` field, no price line in a summary or a record, no price setting in the row: the product does not count the price of a task. The donor's price part is dropped together with its test. What leaves with it is the donor's whole reading of the provider's journal — the `priceJournal` setting and its explanation, the reading itself with its window-coverage rule that refuses a reading whose window was rotated away, the `price` field of the task variant, and the price lines of the summary and of the old block. It was the donor's only reading outside its own storage, so dropping it removes the last dependency on another package's file format.
|
|
109
|
+
- **The reading is tolerant, the writing is strict.** One vocabulary is read three ways: a stored record is read as it is, so a field this journal does not know rides by — not interpreted and not lost; a write through a tool is closed, and a field the journal does not know is refused by name; an answer carries the fields of the record's kind, so a foreign field stays in the store and does not come out through a tool or the route. Refusing the whole unit would have made the page read a refusal instead of one history; trimming the field would have rewritten the home's history. Neither is done.
|
|
110
|
+
- **Tolerance is about fields, not about kinds.** A record of a ninth kind still refuses the unit by name: the vocabulary is closed on purpose, and a new kind is an edit of `domain.js`.
|
|
111
|
+
- **The kinds keep the donor's words.** `состав`, `настройки`, `жизненный цикл`, `оборот` for what the environment observes, `задача`, `шаг`, `отказ`, `сдача` for what a participant hands over — although the plugin's code is English: the words are read by the environment's reader, and a translation would split one history into two languages. This is a decision, not an oversight. The name a print gives its cause is the one part of a record written in the plugin's own words.
|
|
112
|
+
- **The old page does not come over.** The donor's browser half is a block of the old Coreness page and depends on that page's library; this product's own page is a different surface with its own seat and pair, so a section here is written for it rather than moved. What is reused is the shape: one read of one route, a refusal drawn in words, and a block that reads once and keeps what it read.
|
|
113
|
+
- **Nothing tied to the old layout comes over either** — the old block registry and its order, the settings-kit primitives, the old page's counters and its MCP wording. None of it is a contract of the platform.
|
|
114
|
+
- **The route reads, it does not write.** Nothing but the summary and the faults leaves by it; an edit of the journal goes through the tools, where an argument is validated before a record exists.
|
|
115
|
+
- **The tools are the only door for a participant.** No second route, no second writer, no search UI and no charts: the journal answers in lines and counts.
|
|
116
|
+
- **No surface, by decision.** The plugin opens a route and shows nothing on the page: the journal is the agents' instrument, and the user's surface is the settings that arrive with MCP. A surface for the journal is not withheld for lack of a place — it is not wanted.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The journal's domain: the vocabulary of kinds, one record schema, the address
|
|
3
|
+
* that keys a record, and the domain declaration the storage form opens.
|
|
4
|
+
*
|
|
5
|
+
* A record answers the question "what changed the world": the fields and kinds
|
|
6
|
+
* are checked by the schema, not by convention — an unknown kind is refused at
|
|
7
|
+
* write time rather than stored as "other", and a participant's refusal without
|
|
8
|
+
* a reason is refused too. The vocabulary is closed on purpose: the kind is part
|
|
9
|
+
* of the record's address and of the measure the summary counts by, so a new
|
|
10
|
+
* kind is an edit of this file.
|
|
11
|
+
*
|
|
12
|
+
* One history is read tolerantly and written strictly: a stored record may carry a field
|
|
13
|
+
* this vocabulary does not know, and that field rides through the journal untouched — the
|
|
14
|
+
* history of the environment is not rewritten under a newer schema. What the journal writes
|
|
15
|
+
* itself is closed: a field it does not know is refused by name at write time.
|
|
16
|
+
*
|
|
17
|
+
* The kinds stay in the environment's own words — Russian — while everything
|
|
18
|
+
* around them is English: an agent's habit and the records already written are
|
|
19
|
+
* not split by a rename. This is a decision, not an oversight; the plugin's
|
|
20
|
+
* README says the same in words.
|
|
21
|
+
*
|
|
22
|
+
* A record is an address (time, kind, area, name), a shell (task, author) and the
|
|
23
|
+
* body of its kind. The composition fingerprint is carried only by the records
|
|
24
|
+
* that observed the composition: it is the baseline of the next comparison, and
|
|
25
|
+
* that is why rotation drops it last.
|
|
26
|
+
*
|
|
27
|
+
* @module @coreness/dsh/plugins/journal/domain
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { z } from 'zod'
|
|
31
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
32
|
+
|
|
33
|
+
/** Kinds the environment records by itself: what changes without being told. */
|
|
34
|
+
export const ENVIRONMENT_KINDS = ['состав', 'настройки', 'жизненный цикл', 'оборот']
|
|
35
|
+
/** Kinds a participant reports: what cannot be observed from the outside. */
|
|
36
|
+
export const PARTICIPANT_KINDS = ['задача', 'шаг', 'отказ', 'сдача']
|
|
37
|
+
/** The whole vocabulary: closed, because the kind is part of a record's address. */
|
|
38
|
+
export const KINDS = [...ENVIRONMENT_KINDS, ...PARTICIPANT_KINDS]
|
|
39
|
+
|
|
40
|
+
/** The name a participant's record carries when none was given. */
|
|
41
|
+
export const DEFAULT_RECORD_ID = {
|
|
42
|
+
'задача': 'задача',
|
|
43
|
+
'шаг': 'шаг',
|
|
44
|
+
'отказ': 'отказ',
|
|
45
|
+
'сдача': 'сдача',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An instant: canonical UTC with milliseconds — the shape windows are compared in. */
|
|
49
|
+
const instant = z.iso.datetime({ precision: 3 })
|
|
50
|
+
|
|
51
|
+
/** One file's print: a digest and a length, which is what makes an edit visible. */
|
|
52
|
+
const filePrint = z.object({
|
|
53
|
+
digest: z.string().min(1),
|
|
54
|
+
bytes: z.number().int().nonnegative(),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/** The print of the composition: the profile's files, its live rows and one digest. */
|
|
58
|
+
export const compositionSchema = z.object({
|
|
59
|
+
profile: z.object({
|
|
60
|
+
bundles: z.array(z.string()),
|
|
61
|
+
dependencies: z.array(z.string()),
|
|
62
|
+
}),
|
|
63
|
+
files: z.object({
|
|
64
|
+
'package.json': filePrint,
|
|
65
|
+
'cordis.patch.yml': filePrint.nullable(),
|
|
66
|
+
'compatibility.json': filePrint.nullable(),
|
|
67
|
+
}),
|
|
68
|
+
live: z.object({
|
|
69
|
+
bundles: z.array(z.object({ name: z.string(), enabled: z.boolean() })),
|
|
70
|
+
rows: z.array(z.object({ id: z.string(), module: z.string(), enabled: z.boolean() })),
|
|
71
|
+
}),
|
|
72
|
+
digest: z.string().min(1),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** The shell of a record: the address, the task it belongs to and its author. */
|
|
76
|
+
const shell = {
|
|
77
|
+
time: instant,
|
|
78
|
+
area: z.string().min(1),
|
|
79
|
+
id: z.string().min(1),
|
|
80
|
+
task: z.string().min(1).optional(),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The record's variants, built three ways out of one vocabulary: what the medium stores
|
|
85
|
+
* and reads (`loose` — a field the journal does not know rides by, not interpreted and not
|
|
86
|
+
* lost), what a write must be (`strict` — a field the journal does not know is refused by
|
|
87
|
+
* name), and what a record's body is without the fields of the address (`plain`, which is
|
|
88
|
+
* how a foreign record's own fields are picked out for an answer).
|
|
89
|
+
* @param {(shape: object) => object} objectOf the wrapper: `z.looseObject` | `z.strictObject` | `z.object`
|
|
90
|
+
* @returns {object} the record schema
|
|
91
|
+
*/
|
|
92
|
+
const recordVariants = objectOf => z.discriminatedUnion('kind', [
|
|
93
|
+
objectOf({ ...shell, kind: z.literal('состав'), author: z.literal('среда'), changed: z.array(z.string().min(1)).min(1), composition: compositionSchema }),
|
|
94
|
+
objectOf({ ...shell, kind: z.literal('настройки'), author: z.literal('среда'), changed: z.array(z.string().min(1)).min(1), composition: compositionSchema }),
|
|
95
|
+
objectOf({ ...shell, kind: z.literal('жизненный цикл'), author: z.literal('среда'), text: z.string().min(1), reason: z.string().min(1).optional() }),
|
|
96
|
+
objectOf({ ...shell, kind: z.literal('оборот'), author: z.literal('среда'), dropped: z.number().int().nonnegative(), kept: z.number().int().nonnegative(), reason: z.string().min(1) }),
|
|
97
|
+
objectOf({ ...shell, kind: z.literal('задача'), author: z.literal('участник'), task: z.string().min(1), text: z.string().min(1) }),
|
|
98
|
+
objectOf({ ...shell, kind: z.literal('шаг'), author: z.literal('участник'), task: z.string().min(1), stepMs: z.number().int().nonnegative(), text: z.string().optional(), reread: z.string().optional() }),
|
|
99
|
+
objectOf({ ...shell, kind: z.literal('отказ'), author: z.literal('участник'), task: z.string().min(1), reason: z.string().min(1), text: z.string().optional() }),
|
|
100
|
+
objectOf({ ...shell, kind: z.literal('сдача'), author: z.literal('участник'), text: z.string().min(1) }),
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The record schema the medium reads a stored journal with: one variant per kind, the
|
|
105
|
+
* fields of a kind checked, everything else riding through untouched.
|
|
106
|
+
*
|
|
107
|
+
* The reading is tolerant because the journal has one history and it was not written under
|
|
108
|
+
* this schema: a record stored earlier carries fields this schema does not know, and those
|
|
109
|
+
* fields are neither interpreted nor dropped — they are the record's own, and a rewrite of
|
|
110
|
+
* the journal must not lose them. The kind and the fields of a kind are still checked, so a
|
|
111
|
+
* record that cannot be read as one of the vocabulary is refused by name rather than
|
|
112
|
+
* silently counted. A write is the other door: it is strict (see {@link strictRecordSchema}).
|
|
113
|
+
*/
|
|
114
|
+
export const recordSchema = recordVariants(z.looseObject)
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The record schema a write must satisfy: the same vocabulary, closed. A field the journal
|
|
118
|
+
* does not know — the price of a task, for one — is refused by name at write time. The
|
|
119
|
+
* price appears neither here nor anywhere else: the product does not count the price of a task
|
|
120
|
+
* (the user's decision of 06.10), and the donor's price part left with it.
|
|
121
|
+
*/
|
|
122
|
+
export const strictRecordSchema = recordVariants(z.strictObject)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The record schema of an answer: the declared fields only. What the journal does not know
|
|
126
|
+
* stays in the store and does not leave through a tool or the route — an answer carries what
|
|
127
|
+
* the journal counts, and a reader who is handed a record is handed the fields of a record.
|
|
128
|
+
*/
|
|
129
|
+
export const answerSchema = recordVariants(z.object)
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The journal's domain: one table of records, keyed by a record's address.
|
|
133
|
+
*
|
|
134
|
+
* The domain's name carries an underscore while the plugin's row id keeps its
|
|
135
|
+
* hyphen: the medium refuses a hyphen in a unit name (`UNIT_NAME_RE`), and the two
|
|
136
|
+
* are different places anyway — the domain is a file in the storage root, the row
|
|
137
|
+
* is an entry of the composition.
|
|
138
|
+
*/
|
|
139
|
+
export const journalDomain = defineDomain({
|
|
140
|
+
name: 'coreness_journal',
|
|
141
|
+
version: 1,
|
|
142
|
+
tables: { records: domainTable(recordSchema) },
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The address of a record: time, kind, area and name — it is also the table key.
|
|
147
|
+
* @param {object} record a record
|
|
148
|
+
* @returns {string} the address
|
|
149
|
+
*/
|
|
150
|
+
export function addressOf(record) {
|
|
151
|
+
return [record.time, record.kind, record.area, record.id].join('|')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The reason of a refusal in words: the schema has its own message per violation.
|
|
156
|
+
* @param {object | Error} error a parse refusal
|
|
157
|
+
* @returns {string} the reason
|
|
158
|
+
*/
|
|
159
|
+
export function refusalOf(error) {
|
|
160
|
+
const issues = error?.issues ?? []
|
|
161
|
+
if (issues.length === 0) return String(error?.message ?? error)
|
|
162
|
+
return issues
|
|
163
|
+
.map(issue => `${issue.path.join('.') || 'record'}: ${issue.message}`)
|
|
164
|
+
.join('; ')
|
|
165
|
+
}
|