@boxline/sdk 1.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/CHANGELOG.md +187 -0
- package/LICENSE +21 -0
- package/README.md +495 -0
- package/dist/client.d.ts +529 -0
- package/dist/client.js +874 -0
- package/dist/client.js.map +1 -0
- package/dist/core.d.ts +77 -0
- package/dist/core.js +223 -0
- package/dist/core.js.map +1 -0
- package/dist/errors.d.ts +300 -0
- package/dist/errors.js +403 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/pagination.d.ts +34 -0
- package/dist/pagination.js +67 -0
- package/dist/pagination.js.map +1 -0
- package/dist/session.d.ts +252 -0
- package/dist/session.js +345 -0
- package/dist/session.js.map +1 -0
- package/dist/streaming.d.ts +7 -0
- package/dist/streaming.js +71 -0
- package/dist/streaming.js.map +1 -0
- package/dist/types.d.ts +2147 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/webhooks.d.ts +27 -0
- package/dist/webhooks.js +184 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +59 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@boxline/sdk`. The SDK follows [semantic versioning](https://semver.org).
|
|
4
|
+
|
|
5
|
+
## 1.1.0 (not published yet)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Partial results from a several-page extract**: a page that did not load (`page_unreachable`, `page_timeout`) or is
|
|
10
|
+
not a web page is listed in `pages` with `status: null`, `finalUrl: null` and `error: {code, message}`, and the call
|
|
11
|
+
fails only when none load.
|
|
12
|
+
- **`NotAWebPageError`** (422 `not_a_web_page`, `ErrorCode.notAWebPage`, not retried): `fetch` or `extract` of an
|
|
13
|
+
address that answers with a PDF or another document Chrome only displays.
|
|
14
|
+
- **`exec()` reads the streamed answer**: headers come at once, so a command after a long session setup no longer hits
|
|
15
|
+
Node's 300 s headers timeout; `execStream` skips the API's `waiting` and `ping` lines and throws an `error` line as
|
|
16
|
+
the error it names; both allow up to 10 minutes of setup before the command's own time limit.
|
|
17
|
+
- **Own model keys and a default model**: `bx.project.modelKeys()`, `setModelKey(provider, {key?, use?})` and `deleteModelKey(provider)`
|
|
18
|
+
(a key is write-only: `preview` is its last 4 characters); `defaultModel` in `project.settings()` / `setSettings()`;
|
|
19
|
+
`keySource` (`"project"` | `"platform"`) on agent runs, extract results and `agent.models()` providers; `AgentProvider` now also
|
|
20
|
+
`"xai"` (Grok) and `"google"` (Gemini); the plan feature `platformModels` (off on Free) and `ownKeyModelCostUsd` in stats.
|
|
21
|
+
Error codes `invalid_model_key`, `model_key_rejected`, `model_error`.
|
|
22
|
+
- **Save a sign-in from a working session**: `bx.contexts.create({name, fromSession, attach})` makes a saved login from
|
|
23
|
+
the session's current cookies and site storage; `attach: true` also makes the session save to it from now on.
|
|
24
|
+
`ErrorCode.contextTooLarge` (413 over 16 MB); `PlanLimitError` past the plan's `maxContexts` or `maxContextBytes`
|
|
25
|
+
(new `Plan` fields).
|
|
26
|
+
- **Runs whose server stopped**: the run errorCode `server_restarted` (continuable: `agent.continueRun` goes on in the
|
|
27
|
+
same browser; `ErrorCode.serverRestarted`), and `RunNotLiveError` (409 `run_not_live`, `ErrorCode.runNotLive`) from
|
|
28
|
+
`agent.takeover`, `handBack` and `sendMessage` on such a run.
|
|
29
|
+
- **Webhook events for everything a receiver may need**: `WebhookEventType` has `session.started`,
|
|
30
|
+
`session.expiring`, `agent_run.started`, `agent_run.waiting`, `agent_run.resumed`, `captcha.solved`, `captcha.failed`,
|
|
31
|
+
`task_run.started`, `task.schedule_paused`, `usage.limit_reached`, `api_key.created`, `api_key.revoked`,
|
|
32
|
+
`secret.changed`, `webhook.changed`, `extension.uploaded` and `extension.deleted`. `events: ["*"]` subscribes to every
|
|
33
|
+
type (`WebhookSubscription`). Typed payloads: `WebhookEventPayload` (a union keyed by `type`, for
|
|
34
|
+
`verifyWebhook<WebhookEventPayload>(…)`), `WebhookEventDataMap` and one interface per `data` shape
|
|
35
|
+
(`WebhookAgentRunWaitingData`, `WebhookUsageLimitData`, …); `WebhookEvent` has `test?`. `bx.webhooks.eventTypes()`
|
|
36
|
+
(`GET /v1/webhooks/events`, `WebhookEventTypeList`); `bx.webhooks.test(id, {type})` sends a sample of any type.
|
|
37
|
+
`verifyWebhook` is unchanged.
|
|
38
|
+
- Agent runs carry `variableNames` (the names of the run's own variables, never values): what `continueRun` needs again.
|
|
39
|
+
|
|
40
|
+
- **Continue a run that stopped at a limit**: `bx.agent.continueRun(runId, {maxSteps, maxCostUsd, instruction,
|
|
41
|
+
variables})` returns the new run (same session; `continuedFrom`), which works with `wait` and `stream` like any run.
|
|
42
|
+
Runs carry `continuable` (`{until}` or null), `continuedFrom`, `continuedBy`, `sessionExpiresAt`, `maxSteps`,
|
|
43
|
+
`maxCostUsd` and `maxConsecutiveErrors`; the stream's `done` event carries `continuable`. Types `ContinueRunParams`,
|
|
44
|
+
`AgentRunErrorCode`; `NotContinuableError` (409 `not_continuable`) and `SessionNotRunningError`.
|
|
45
|
+
- **Messages to a working run**: `bx.agent.sendMessage(runId, text)` (`AgentMessageSent`); delivered messages are
|
|
46
|
+
`message` steps (`from`, `id`, `sentAt`, `delivered`) in the run and its stream. `TooManyMessagesError` after 50.
|
|
47
|
+
- **Run limits**: `maxSteps` takes 1–1000 or `null` (no step limit), `maxCostUsd` (a money budget) and
|
|
48
|
+
`maxConsecutiveErrors` on `agent.run`; `timeout` and `idleTimeout` for the run's own session. New run errorCodes
|
|
49
|
+
(`ErrorCode.maxSteps`, `maxCost`, `tooManyErrors`, `noProgress`, `sessionTimeout`, `sessionEnded`).
|
|
50
|
+
- **Idle timeout**: `idleTimeout` on `sessions.create` and `sessions.update` (null switches it off); sessions carry
|
|
51
|
+
`idleTimeout`, and `endReason` can be `"idle"` (`SessionEndReason`).
|
|
52
|
+
- Tasks: `maxSteps: null` (no step limit), `maxCostUsd`, and `timeout` / `idleTimeout` in `browser`.
|
|
53
|
+
- `IDEMPOTENT_POSTS` has `/v1/agent/runs/:id/continue` and `/v1/agent/runs/:id/messages`: both send an Idempotency-Key
|
|
54
|
+
and are retried like the other creates.
|
|
55
|
+
- **Tasks** (saved agent runs): `bx.tasks.create/list/get/update/delete/run/runs`, with `%name%` variables (plain,
|
|
56
|
+
or `secret: true` with `origins` and `shell`), `secrets` (project secret names, usable by scheduled tasks too), an
|
|
57
|
+
`output` schema, `browser` settings, `savedLogin`, `model`, `maxSteps` and a `schedule` (`cron`, `timezone`, `variables`, `enabled`; `nextRunAt` and `nextRuns`, the next 3 times in UTC, on the schedule; `lastRun`, the newest run, on the task; `null` removes a
|
|
58
|
+
field on update, schedule fields are merged). `tasks.runs(id, {status})` pages a task's run history by cursor; a
|
|
59
|
+
run's status can be `queued` (a scheduled run waiting for its turn), `skipped` and `missed` too.
|
|
60
|
+
`tasks.waitForRun(run | taskId, taskRunId, {pollMs, timeoutMs})` waits for a task run's result. Types `Task`,
|
|
61
|
+
`TaskRun<T>`, `TaskRunStatus`, `TaskVariable`, `TaskBrowser`, `TaskSchedule`, `TaskLastRun`, `TaskScheduleInput`,
|
|
62
|
+
`TaskCreateParams`, `TaskUpdateParams`, `TaskRunParams`, `TaskRunListParams`, `WaitOptions`.
|
|
63
|
+
- **Structured output**: `output` (a JSON Schema, `OutputSchema`) on `agent.run`. Runs carry `resultText`, `output`,
|
|
64
|
+
`errorCode` (`ErrorCode.outputInvalid`), `taskId` and `taskRunId`; the stream's `done` event carries `resultText`
|
|
65
|
+
and `errorCode`. `AgentRun<T>`, `agent.get<T>`, `agent.wait<T>` and `agent.stream<T>` type the JSON answer
|
|
66
|
+
(`result` stays `string` by default, as before).
|
|
67
|
+
- `MissingVariablesError` (400 `missing_variables`) and `PlanLimitError` (402 `plan_limit`).
|
|
68
|
+
- Saving a task and starting a task run send an Idempotency-Key and are retried like the other creates:
|
|
69
|
+
`IDEMPOTENT_POSTS` has `/v1/tasks` and `/v1/tasks/:id/runs` (`:id` is one path segment; `isIdempotentPost(route)`).
|
|
70
|
+
- `examples/tasks.ts`.
|
|
71
|
+
- **Project secrets** (write-only): `bx.secrets.list/create/get/update/delete/audit`, with `scope` (`"agent"`,
|
|
72
|
+
`"shell"`, `"all"`), `origins`, `shell` and `description`; `preview` shows the last 4 characters of long values,
|
|
73
|
+
never more. `secrets.audit({name})` pages the changes and uses (who, and what used it). Types `Secret`,
|
|
74
|
+
`SecretScope`, `SecretCreateParams`, `SecretUpdateParams`, `SecretAuditEntry`, `SecretAuditParams`. Creating a
|
|
75
|
+
secret is not retried (the API takes no Idempotency-Key there).
|
|
76
|
+
- **Saved login details with 2FA**: `bx.contexts.setLogin(id, {origin, username, password, totpSecret})` (a full
|
|
77
|
+
replace), `contexts.updateLogin(id, {origin?, username?, password?, totpSecret?})` (keeps what is not sent;
|
|
78
|
+
`totpSecret: null` removes 2FA; a new `origin` needs `password`, and `totpSecret` when the login has 2FA) and
|
|
79
|
+
`contexts.deleteLogin(id)`; contexts carry `login` (`ContextLogin`: never the password or the 2FA secret);
|
|
80
|
+
`loginDetails` in `PlanFeature`. Types `LoginDetails`, `LoginDetailsUpdate`, `ContextLogin`.
|
|
81
|
+
- **Shell environment and secrets**: `env` and `secrets` on `sessions.create` (sessions show the names in `env` and
|
|
82
|
+
`secrets`), `secrets` on `exec` and `execStream` for one command; `sessions.move` also returns `shell` (`MoveShell`:
|
|
83
|
+
the directory, the exported variables that came along, the processes that were stopped).
|
|
84
|
+
- **Secrets for the AI**: `secrets` on `agent.run` (and `context`, a saved login for the run's own session), on
|
|
85
|
+
`session.step()` (`StepOptions`) and the step action, and on `runScript` with `login` (the saved login's details for
|
|
86
|
+
`step()`); `allowWithExtensions` on steps and `runScript`, as on `agent.run`.
|
|
87
|
+
- `TooManySecretValuesError` (409 `too_many_secret_values`), `SecretExistsError` (409 `secret_exists`),
|
|
88
|
+
`SecretNotAllowedError` (400 `secret_not_for_ai` / `secret_not_for_shell`), `MachineTooOldError` (409
|
|
89
|
+
`machine_too_old`); `PlanLimitError` also for secrets beyond `maxSecrets`. `plan_limit` is always 402 (a session longer than the plan allows was 403).
|
|
90
|
+
- Plans carry `tasks`, `schedules` and `maxSecrets`.
|
|
91
|
+
- `examples/secrets.ts`.
|
|
92
|
+
- `emailVerified` and `termsCurrentVersion` on `me().user`; `"account_recovered"` as a webhook endpoint's
|
|
93
|
+
`disabledReason` and a session's `endReason` (a password reset recovered an account whose email was not confirmed).
|
|
94
|
+
|
|
95
|
+
## 1.0.0 (not published yet)
|
|
96
|
+
|
|
97
|
+
The first stable release: every public API operation has a method, and calls are retried safely.
|
|
98
|
+
|
|
99
|
+
### Added
|
|
100
|
+
|
|
101
|
+
- **Web search**: `bx.search({query, limit, country, language, recency, safeSearch, fetch, proxy})`, with the top
|
|
102
|
+
pages as Markdown when `fetch` is set; `SearchUnavailableError`; `webSearch` in `PlanFeature`, `searchesPerMonth` and
|
|
103
|
+
`extraSearchesPer1000Usd` on plans, `Usage.searches`, `Pricing.webSearch`.
|
|
104
|
+
- **Mouse, keyboard and computer use**: `session.mouse.move/moveBy/click/down/up/drag`, `session.hover`,
|
|
105
|
+
`session.keyboard.key/type/press`, `session.cursor()`; `click` takes `button`, `count` and `modifiers`, `scroll`
|
|
106
|
+
takes `deltaX` and `modifiers`, `screenshot` takes `maxWidth` and `cursor`; the `move`, `hover`, `mouse_down`,
|
|
107
|
+
`mouse_up`, `drag`, `key` and `cursor` actions; bare strings in action lists are plain-English steps; results carry
|
|
108
|
+
`text`. `session.computer(action, {maxWidth, screenshot, format, quality, cursor})` / `bx.sessions.computer(id, …)`
|
|
109
|
+
runs one Anthropic- or OpenAI-shaped computer-use action and returns the screen; `OutOfViewportError`.
|
|
110
|
+
- **Agent**: `agent.run({mode: "computer"})`, `mode` on runs, `thought` on steps and the `thought` stream event,
|
|
111
|
+
`supportsComputerUse` and `computerTool` in `agent.models()`.
|
|
112
|
+
- `ModelRefusedError` (422 `model_refused`, extract).
|
|
113
|
+
- **Webhooks**: `bx.webhooks.create/list/get/update/delete/rotateSecret/test/deliveries/retryDelivery` (deliveries
|
|
114
|
+
page by cursor, filter by `status`, and carry `errorCode` and their attempt `history`; endpoints carry
|
|
115
|
+
`pausedUntil`), and `verifyWebhook(body, header, secret | secrets, {toleranceSeconds})`, which accepts exactly
|
|
116
|
+
what the API's signer makes (checked against its vectors) and throws `WebhookSignatureError` (`reason`);
|
|
117
|
+
`webhookSignatureHeader()` for tests. `WebhookUrlNotAllowedError`, `WebhooksUnavailableError`,
|
|
118
|
+
`WebhookDisabledError`, `PayloadExpiredError`, and the `queue_full` code; `webhookEndpoints` on plans.
|
|
119
|
+
- **Project settings**: `bx.project.settings()` and `setSettings({captchaDefault})`.
|
|
120
|
+
- **Accounts and retention**: `auth.signup({name})`; `name` on users, `termsVersion` and `termsUpdate` on `me().user`;
|
|
121
|
+
`retentionDays` on plans (how long an ended session's recording, logs and run steps are kept) and `dataDeletedAt`
|
|
122
|
+
on sessions (when they were deleted).
|
|
123
|
+
- **Browser settings**: `blockAds` and `cookieBanners` (`"reject"` | `"off"`) on `sessions.create`, `update` and
|
|
124
|
+
`agent.run`; `blockAds` on `fetch`, `screenshot`, `pdf`, `extract` and `crawl.start`; sessions carry `blockAds`,
|
|
125
|
+
`blockedRequests`, `cookieBanners` and `extensions`.
|
|
126
|
+
- **Chrome extensions**: `bx.extensions.upload(zip)` (bytes, an ArrayBuffer, a Blob or a file path; raw
|
|
127
|
+
`application/zip`; with an automatic Idempotency-Key), `list`, `get`, `delete`; `extensions: [id]` on
|
|
128
|
+
`sessions.create` and `agent.run`; `allowWithExtensions` on `agent.run`; `ExtensionInfo`; `extensions` in
|
|
129
|
+
`PlanFeature`. `VariablesWithExtensionsError`, `ExtensionDeniedError`, `CrossSiteRequestError`,
|
|
130
|
+
`InvalidExtensionError`, `PayloadTooLargeError` and `LimitReachedError`.
|
|
131
|
+
|
|
132
|
+
- **Every public endpoint.** New: `auth.signup`, `auth.login`, `auth.logout`, `apiKeys.list/create/revoke`,
|
|
133
|
+
`sessions.live` (fresh signed URLs, also `session.live()`), `sessions.streamEvents` (server-sent events),
|
|
134
|
+
`sessions.recordingFrame`, `agent.stream` (a run as it happens), `crawl.pages`, `pricing`, `openapi`, `health`.
|
|
135
|
+
Session methods are also on `bx.sessions` with the id first (`bx.sessions.pause(id)`), next to the `Session` object.
|
|
136
|
+
- **Browser helpers** on a session: `type`, `press`, `scroll`, `wait`, `select`, `elements`, `evaluate`, `upload`,
|
|
137
|
+
`tabs`, `newTab`, `switchTab`, `closeTab`, `back`, `forward`, `reload`; `click` also takes `{x, y}`.
|
|
138
|
+
- **Retries** with exponential backoff (0.5 s to 8 s) and jitter after network errors, time-outs, 429 and 5xx, for
|
|
139
|
+
GETs and for the calls that create or start something; `Retry-After` and `RateLimit-Reset` are honoured. Option
|
|
140
|
+
`maxRetries` (default 2), per client or per call.
|
|
141
|
+
- **Idempotency keys**: a new `Idempotency-Key` per create call, reused by its retries; pass `idempotencyKey` for your
|
|
142
|
+
own.
|
|
143
|
+
- **Time limits**: `timeoutMs` per client (default 120 s) or per call; long calls get the time they need.
|
|
144
|
+
- **Typed errors**: `BoxlineError` now has `requestId` (the API's) and `clientRequestId` (yours), `headers`, `body` and
|
|
145
|
+
`retryable`, with subclasses `RateLimitError`, `FeatureNotInPlanError`, `ProjectSuspendedError`,
|
|
146
|
+
`IdempotencyMismatchError`, `IdempotencyInProgressError`, `InvalidCursorError`, `CaptchaTimeoutError`,
|
|
147
|
+
`PageUnreachableError`, `PageTimeoutError`, `AuthenticationError`, `NotFoundError`, `BoxlineConnectionError`,
|
|
148
|
+
`BoxlineTimeoutError`, and the `ErrorCode` constants.
|
|
149
|
+
- **Cursor pages**: every list takes `limit` and `after` and returns a `Page` (`data`, `next`, `hasNextPage()`,
|
|
150
|
+
`getNextPage()`, `iterPages()`); `for await` over a list goes through every page.
|
|
151
|
+
- `clientRequestId` option (sent as `X-Client-Request-Id`), `headers` option, `bx.withOptions()`, the
|
|
152
|
+
`Boxline-SDK: node/1.0.0` header.
|
|
153
|
+
- Types matched to the API description: `me().user` can be null, `Usage.from/to/running`, `AgentRun.createdAt` and
|
|
154
|
+
`finishedAt`, CAPTCHA steps, agent-run variables with `origins` and `shell`, `fetch` `links`/`delayMs`/`viewport`,
|
|
155
|
+
`ExecResult.truncated`, `CrawlJob.next` as a cursor string, the `select` and `elements` actions, and the session's
|
|
156
|
+
`checkpointAt`, `recoveries`, `error`, `recordSession`, `hasRecording`, `setup`, `setupStatus`, `setupError`.
|
|
157
|
+
- Runnable examples in `examples/`, including browser + shell + files in one session.
|
|
158
|
+
|
|
159
|
+
### Changed (breaking)
|
|
160
|
+
|
|
161
|
+
- The default API is `https://api.boxline.dev` (it was `http://localhost:8080`); `BOXLINE_API_URL` and `baseUrl`
|
|
162
|
+
still choose another one.
|
|
163
|
+
- `auth.signup` needs `acceptTerms: true` (the user accepts the terms of service and acceptable use policy); the API
|
|
164
|
+
refuses a signup without it (400 `terms_not_accepted`).
|
|
165
|
+
|
|
166
|
+
- List methods return a `Page` instead of an array: use `(await bx.agent.list()).data`, or `for await`.
|
|
167
|
+
`agent.list` and `crawl.list` take `{limit, after}` instead of a number.
|
|
168
|
+
- `crawl.get(id, {limit, after})`: the `offset` option is gone (the API still accepts it), and `CrawlJob.next` is a
|
|
169
|
+
cursor string, not a number.
|
|
170
|
+
- `session.events()` resolves to a `Page` (`data`, `nextAfter` and `next` as before).
|
|
171
|
+
- `BoxlineError`'s constructor takes a fourth `details` argument; errors are instances of the subclasses above.
|
|
172
|
+
- `request()` and `send()` take request options (`timeoutMs`, `maxRetries`, `headers`, …) as their fourth argument.
|
|
173
|
+
|
|
174
|
+
### Fixed
|
|
175
|
+
|
|
176
|
+
- Logging a client or a session (`console.log`, `JSON.stringify`) no longer shows the API key: the client's key is
|
|
177
|
+
kept out of enumerable fields, and a session serializes to its data only.
|
|
178
|
+
|
|
179
|
+
### Deprecated
|
|
180
|
+
|
|
181
|
+
- `sessions.page()`: use `sessions.list()` (its first page has `total`).
|
|
182
|
+
- `session.shell.restart()` and `session.browser.exportCookies()`: use `session.restartShell()` and
|
|
183
|
+
`session.exportCookies()`.
|
|
184
|
+
|
|
185
|
+
## 0.1.0
|
|
186
|
+
|
|
187
|
+
- The first version: sessions, actions, exec, files, contexts, the agent, crawl and the quick APIs.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boxline
|
|
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.
|