zero-slop 2.9.2 → 2.10.1

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/README.md CHANGED
@@ -7,14 +7,12 @@
7
7
  </a>
8
8
  </p>
9
9
 
10
- Zero Slop crossed 100 GitHub stars and 2,000 npm downloads in its first 12 days.
10
+ Zero Slop crossed 100 GitHub stars and 2,000 npm downloads in its first 12 days.
11
11
 
12
- <h1 align="center">Zero Slop</h1>
13
-
14
- <p align="center"><strong>Find AI-sounding writing. Keep the source intact.</strong></p>
12
+ <p align="center"><strong>Find AI-sounding tells and slop in your writing.</strong></p>
15
13
 
16
14
  <p align="center">
17
- Zero Slop finds stock phrasing, mechanical rhythm, vague claims, and canned formatting.<br>
15
+ Zero Slop finds slop- stock phrasing, mechanical rhythm, vague claims, and canned formatting in writing.<br>
18
16
  Your existing AI assistant edits the draft; local checks guard its names, numbers, links, quotations, code, tables, and paths.
19
17
  </p>
20
18
 
@@ -32,12 +30,12 @@ Zero Slop crossed 100 GitHub stars and 2,000 npm downloads in its first 12 days.
32
30
 
33
31
  <p align="center">
34
32
  <a href="https://github.com/manavmishra/ZeroSlop/actions/workflows/validate.yml"><img alt="Validate" src="https://github.com/manavmishra/ZeroSlop/actions/workflows/validate.yml/badge.svg"></a>
35
- <img alt="Version 2.9.2" src="https://img.shields.io/badge/version-2.9.2-72528F?color=C15732">
33
+ <img alt="Version 2.10.1" src="https://img.shields.io/badge/version-2.10.1-72528F?color=C15732">
36
34
  <a href="https://www.npmjs.com/package/zero-slop"><img alt="npm version" src="https://img.shields.io/npm/v/zero-slop?color=C15732"></a>
37
35
  <a href="https://www.npmjs.com/package/zero-slop"><img alt="npm downloads" src="https://img.shields.io/npm/dm/zero-slop?color=17634F"></a>
38
36
  <a href="https://github.com/manavmishra/ZeroSlop/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/manavmishra/ZeroSlop?style=flat&color=C15732"></a>
39
37
  <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-141412"></a>
40
- <a href="https://hol.org/guard/plugins"><img alt="Listed in the HOL plugin registry" src="https://img.shields.io/badge/HOL%20registry-listed-2C6E8F"></a>
38
+ <a href="https://hol.org/registry/plugins/manav-mishra%2Fzero-slop"><img alt="Listed in the HOL plugin registry" src="https://img.shields.io/badge/HOL%20registry-listed-2C6E8F"></a>
41
39
  </p>
42
40
 
43
41
  ```sh
@@ -52,7 +50,7 @@ npx skills add manavmishra/ZeroSlop --global
52
50
  </picture>
53
51
  </a>
54
52
 
55
- <p align="center"><a href="assets/zero-slop-demo.mp4?v=dark-shell-restored-20260906">Watch the 15-second shell demo</a> · <sub>The original demo uses a longer sample than the example below.</sub></p>
53
+
56
54
 
57
55
  ## Before and after
58
56
 
@@ -82,11 +80,11 @@ The rewrite retains the draft's stated result. See four complete, reproducible p
82
80
  - Edit a research summary without flattening its qualifications.
83
81
  - Gate a folder of generated copy before it ships.
84
82
 
85
- Zero Slop is a writing tool. It does not detect authorship; its score describes the text.
83
+ The score describes writing patterns, not authorship.
86
84
 
87
85
  ## Install
88
86
 
89
- Paste a draft into [zero-slop.ai/try](https://zero-slop.ai/try/). The free editor returns the edit, the before-and-after scores, and the exact phrases that triggered the scorer.
87
+ [Try the free browser editor](https://zero-slop.ai/try/), or install:
90
88
 
91
89
  | Environment | Fastest route |
92
90
  |---|---|
@@ -97,19 +95,19 @@ Paste a draft into [zero-slop.ai/try](https://zero-slop.ai/try/). The free edito
97
95
  | Claude.ai | Upload the [latest skill ZIP](https://github.com/manavmishra/ZeroSlop/releases/latest/download/zero-slop.zip) |
98
96
  | ChatGPT, Claude, Grok, Gemini, Cursor, or another MCP client | Connect the optional [hosted MCP server](mcp/README.md) |
99
97
 
100
- Once installed, ask your AI assistant to edit a draft:
98
+ Ask your assistant to edit:
101
99
 
102
100
  ```text
103
101
  /zero-slop (your writing)
104
102
  ```
105
103
 
106
- Inspect a draft without changing it:
104
+ Inspect without editing:
107
105
 
108
106
  ```text
109
107
  /zero-slop inspect (your writing)
110
108
  ```
111
109
 
112
- Score a file locally:
110
+ Score locally:
113
111
 
114
112
  ```sh
115
113
  npx zero-slop score draft.md
@@ -121,17 +119,43 @@ From a cloned checkout, gate a folder:
121
119
  python3 scripts/slopscore.py --batch drafts/ --gate 25
122
120
  ```
123
121
 
124
- Installed checks run locally; editing follows your AI assistant's privacy settings. The optional hosted MCP processes drafts remotely. See its [privacy details](mcp/README.md).
122
+ Installed checks run locally. Skill editing follows your assistant's privacy settings; [MCP editing is remote](mcp/README.md).
125
123
 
126
124
  ### Prefer one hosted connection? Use the MCP
127
125
 
128
- Connect the [Zero Slop MCP](https://zero-slop.ai/#mcp) once to edit drafts inside your MCP client. Its `deslop` tool returns the edit, before-and-after scores, and review status. Zero Slop requires no account or API key; server updates are managed for you.
126
+ Connect the [Zero Slop MCP](https://zero-slop.ai/#mcp) to edit drafts inside your MCP client. No account or API key required.
129
127
 
130
128
  ```text
131
129
  https://mcp.zero-slop.ai/mcp
132
130
  ```
133
131
 
134
- See [`DISTRIBUTION.md`](DISTRIBUTION.md) for direct connector commands and directory status.
132
+ [Connection options and listing status](DISTRIBUTION.md).
133
+
134
+ ## CLI
135
+
136
+ Edit a file through MCP:
137
+
138
+ ```sh
139
+ npx --yes zero-slop@2.10.1 deslop draft.md --genre professional
140
+ ```
141
+
142
+ Use `-` for stdin and `--json` for structured output. `--require-approved` exits nonzero
143
+ when review is needed; the result is still printed. Files stay unchanged. Node.js 22+;
144
+ offline `score` also needs Python 3. [CLI reference and privacy](docs/cli.md).
145
+
146
+ ## REST API
147
+
148
+ ```sh
149
+ curl --fail-with-body --max-time 75 https://mcp.zero-slop.ai/v1/deslop \
150
+ -H 'Content-Type: application/json' \
151
+ --data '{"text":"Maya owns the pricing review.","genre":"professional"}'
152
+ ```
153
+
154
+ Same pipeline and result as MCP. Check `status` before using the edit. Free, shared
155
+ capacity; up to 20,000 Unicode code points per draft after trimming. Hosted CLI
156
+ editing and REST process drafts remotely without storing them.
157
+
158
+ [API reference](docs/rest-api.md) · [OpenAPI contract](https://mcp.zero-slop.ai/openapi.json)
135
159
 
136
160
  ## What the workflow adds
137
161
 
@@ -230,8 +254,8 @@ stress case took 2.2932 seconds, and learning from an 8,000-word edit took
230
254
  0.1592 seconds. The measurements and machine details are in
231
255
  [`bench/performance-results.json`](bench/performance-results.json).
232
256
 
233
- Across 12 interleaved runs against 2.7.7, we measured 2.57% higher median throughput,
234
- which is effectively unchanged. The separate two-way replay used
257
+ Across 12 interleaved runs against 2.7.7, we measured 0.71% lower median throughput,
258
+ within the 5% regression limit. The separate two-way replay used
235
259
  Zero Slop v2.6.0.
236
260
 
237
261
  The [RAID+ audit](bench/raid-plus-corpus/README.md) checks how the scorer responds
package/SECURITY.md ADDED
@@ -0,0 +1,163 @@
1
+ # Security posture
2
+
3
+ ## Runtime boundary
4
+
5
+ The installed skill ships eight standard-library Python modules:
6
+
7
+ | File | Purpose | Writes | Network |
8
+ |---|---|---|---|
9
+ | `scripts/slopscore.py` | score, heatmap, and fidelity checks | one-time interactive-note state under `$ZERO_SLOP_HOME`; never the draft | none |
10
+ | `scripts/register.py` | document-level measurements and validated final-review packets | none | none |
11
+ | `scripts/predictability.py` | create and score deterministic cloze probes | none | none |
12
+ | `scripts/rerank.py` | rank candidate rewrites | optional user-selected output | none |
13
+ | `scripts/learn.py` | private online learning, named scoring profiles, reviewed imports/exports | `$ZERO_SLOP_HOME`; shared taxonomy only through explicit maintainer `--merge --apply` | none |
14
+ | `scripts/calibrate.py` | corpus calibration and shared-pattern maintenance | explicit calibration output or shared learned data | none |
15
+ | `scripts/safeio.py` | locks and atomic file replacement | only on behalf of the two writers above | none |
16
+ | `scripts/version_check.py` | optional release check | none | one metadata-only GitHub API request |
17
+
18
+ These local Python checks do not transmit the draft. Editing inside an assistant
19
+ follows that assistant's privacy settings. The version checker sends only a GET
20
+ for the latest public release tag, times out after 2.5 seconds, fails open, and can be
21
+ disabled with `ZS_NO_UPDATE_CHECK=1`.
22
+
23
+ An ordinary interactive score may update a local counter and show one optional
24
+ GitHub-star note after the third run. That state remains under `$ZERO_SLOP_HOME`, is
25
+ never transmitted, and is skipped for pipes, JSON, batch, and gate runs. Set
26
+ `ZERO_SLOP_NO_NOTES=1` to disable it.
27
+
28
+ Build, packaging, benchmark, chart, PDF, website, and test utilities remain in the
29
+ repository but are excluded from the installed plugin runtime.
30
+ `scripts/contextual.py` is one of those maintainer-only research utilities; it is
31
+ not a production feature and cannot change a live draft or score.
32
+
33
+ ## Remote service boundary
34
+
35
+ The npm CLI's explicit `deslop` command is a remote operation. It reads one selected
36
+ file or standard input and sends the text, genre, and optional audience to the public
37
+ MCP endpoint below. It does not transmit file paths or private learning profiles,
38
+ overwrite the source, retry automatically, or follow redirects with draft content.
39
+ The `score` command remains offline. The Node.js transport is separate from the
40
+ stdlib-only Python runtime.
41
+
42
+ The optional public MCP at `https://mcp.zero-slop.ai/mcp` is a separate remote
43
+ service. A draft sent to that endpoint must leave the client to be edited. It is
44
+ processed in memory, excluded from the demo cache, and routed through model endpoints
45
+ configured for zero retention and no training. The gateway rejects any editor
46
+ response that does not confirm `stored: false`. Zero Slop does not log or retain the
47
+ draft or rewrite.
48
+
49
+ The REST endpoint `https://mcp.zero-slop.ai/v1/deslop` calls this same pipeline.
50
+ It accepts at most 128 KiB of UTF-8 JSON and one trimmed draft of 20,000 Unicode code
51
+ points. REST and MCP share an edge capacity limiter. That limiter operates per
52
+ Cloudflare location; a separate atomic budget reservation gates every hosted
53
+ editing-model call across REST, MCP, CLI and the browser demo. Adding a transport
54
+ does not create an independent editing allowance. REST errors omit request content. Responses use `no-store`,
55
+ and there is no stored response replay or idempotency cache.
56
+
57
+ The editor fails closed when its shared daily budget or per-client limit is reached,
58
+ or when budget enforcement is unavailable. Capacity is reserved before inference;
59
+ failed or timed-out model calls are not refunded because their usage may be unknown.
60
+ No automatic model retry or paid-model fallback is used. Local scoring is not metered.
61
+ The service derives a keyed daily hash of the trusted connection IP to coordinate
62
+ abuse limits. This separate counter stores the daily hash and counts, never the raw
63
+ IP or draft, and does not join them to analytics. Shared networks can share a limit.
64
+ Active daily identifiers expire after the UTC day. Cloudflare's SQLite recovery
65
+ history may retain earlier database states for up to 30 days; this history contains
66
+ no drafts or raw IP addresses.
67
+ The project budget bounds Zero Slop's reserved model usage, not other applications'
68
+ usage or the account's existing hosting charges.
69
+
70
+ REST adds completed results, failures, and capacity rejections to the existing
71
+ aggregate counters. It does not increment MCP initialization or tool-call counts.
72
+ REST logs contain only event name, outcome, character count, and elapsed time.
73
+
74
+ The gateway writes aggregate product and reliability events to Cloudflare Analytics
75
+ Engine. Recorded fields are limited to JSON-RPC method, tool, normalized client family
76
+ and major version, MCP protocol version, coarse country and data-center code, genre,
77
+ character and word counts, before and after scores and flag counts, result status,
78
+ completed-check counts, duration, and capacity outcome. Drafts, rewrites, prompts,
79
+ detected phrases, IP addresses, raw user agents, cookies, email addresses, and stable
80
+ user or session identifiers are excluded. Initializations are reported as connections,
81
+ not unique people. Analytics Engine retains the dataset for three months.
82
+
83
+ Hosted events also record the entry channel (MCP, CLI, REST, or the web editor),
84
+ approval category and model-attempt count. The CLI sends a fixed app-family and
85
+ major-version header on its existing hosted requests; this is self-reported
86
+ attribution, not identity. Offline commands send no analytics. The web endpoint
87
+ does not claim to measure the browser's final source checks. Internal signed
88
+ gateway model calls are excluded from the web channel to prevent double counting.
89
+
90
+ Telemetry writes are non-blocking and wrapped so an analytics failure cannot fail an
91
+ MCP call. The daily report uses sampling-aware aggregate queries and degrades to a
92
+ clearly labeled missing section if the dataset cannot be read.
93
+
94
+ Thirty-two SQLite-backed Durable Object shards keep lifetime counters for aggregate
95
+ MCP events: initializations, tool calls, completed results, changed messages,
96
+ warnings, failures, and capacity rejects. Each write is an atomic transaction with
97
+ a one-hour idempotency record, so bounded retries cannot double-count an event. The
98
+ protected report aggregates every shard and the legacy `global` object, preserving
99
+ totals recorded before sharding. It stores no request content or stable identity.
100
+ A report-only bearer secret protects
101
+ the read endpoint; the editor signing secret is never reused. Because MCP provides
102
+ no stable installation identifier, the service does not claim that an initialization
103
+ count is a unique-install count.
104
+
105
+ ## Online-learning isolation
106
+
107
+ Reflection evidence, local detector rules, recurring rewrite preferences, logs, and
108
+ named scoring profiles live under
109
+ `$ZERO_SLOP_HOME` (default `~/.zero-slop`) with owner-only file permissions. They are
110
+ not committed and are not overwritten by skill updates. The scorer loads the reviewed
111
+ shared taxonomy first, then this private overlay on every run.
112
+
113
+ One edit cannot activate a pattern. Phrase evidence needs the same cut across three
114
+ content-distinct before/after pairs; single words need five. Candidate rules must also
115
+ be new and must not match or borrow four consecutive words from the certified human
116
+ corpus. Repeated kept-text evidence can lower a local weight. Reconfirmation keeps a
117
+ local rule current; stale local detector rules decay after 18 months. A rewrite
118
+ preference also needs the same replacement across three content-distinct pairs and is
119
+ retired after 18 months without confirmation.
120
+
121
+ These controls limit blast radius; they do not make feedback trustworthy in the
122
+ cryptographic sense. A determined local user controls their own overlay. Shared changes
123
+ still require an explicit export, review, re-gating, tests, version bump, and release.
124
+
125
+ ## File integrity and path safety
126
+
127
+ Learning uses process-safe lock directories plus same-directory atomic replacement, so
128
+ concurrent reflections cannot silently overwrite one another and a crash cannot leave
129
+ half-written JSON. Corrupt reflection state fails closed instead of being reset.
130
+ Malformed learned rules degrade to the last valid shared layer rather than crashing the
131
+ scorer.
132
+
133
+ Voice-profile names are restricted to a short filename-safe alphabet. Contribution
134
+ exports must remain inside the working directory, cannot target `data/`, and cannot
135
+ overwrite an existing file. Imported contributions are untrusted: Zero Slop discards
136
+ their regexes, rebuilds patterns locally from the reviewed spans, and reruns the safety
137
+ gate.
138
+
139
+ The npm installer stages a complete copy before replacement. `--force` refuses roots,
140
+ the home directory, the current project, symlinks, files, and nonempty directories that
141
+ do not contain a verifiable Zero Slop runtime. A failed copy leaves the installed skill
142
+ in place.
143
+
144
+ ## Known limits
145
+
146
+ - A sample-based `--voice` profile records an existing lexicon or context-gated
147
+ watchlist term after one exact word match. It must be selected explicitly and
148
+ does not learn cadence, syntax, humor, tone, arbitrary phrases, or the writer's
149
+ full style.
150
+ - The human safety corpus contains twelve prose samples. It is a regression floor, not
151
+ proof that a pattern is safe for every dialect, genre, or language.
152
+ - The 0–100 result is a transparent heuristic surface score, not a calibrated
153
+ probability that AI wrote the text.
154
+ - Scripted fidelity checks cover figures, names, quotations, links, asserted
155
+ feelings, and protected document structure. Claim meaning, qualifiers, and voice
156
+ still require the final semantic review described in `SKILL.md`.
157
+ - Feedback recurrence proves content diversity, not independent authorship. Local
158
+ isolation prevents that limitation from changing the shared detector automatically.
159
+
160
+ ## Reporting
161
+
162
+ Open a GitHub issue, or use the email address on the maintainer's GitHub profile for a
163
+ report you would rather not file publicly.
package/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: zero-slop
3
3
  license: MIT
4
4
  metadata:
5
- version: "2.9.2"
5
+ version: "2.10.1"
6
6
  author: manavmishra
7
7
  description: Turn drafts into sharp, natural prose or inspect them without rewriting. Zero Slop runs inside the user's existing AI assistant; Claude, GPT, or another compatible model reads and edits in context while local tools point to exact phrases and protect the source. Use when the user asks to humanize or de-slop writing, inspect AI-sounding patterns, fix text that reads like ChatGPT, polish outward-facing prose, draft social or LinkedIn content, or apply a final quality check to prose the agent generated. The workflow preserves facts, voice, and format and learns privately from repeated, reason-labelled human edits.
8
8
  ---
@@ -18,6 +18,11 @@ context, and performs the editorial work. The bundled local tools handle
18
18
  repeatable checks. They do not replace the assistant, and no separate Zero Slop
19
19
  model or service receives the draft.
20
20
 
21
+ The separately invoked npm `zero-slop deslop` command and hosted MCP/REST endpoints
22
+ send a draft to Zero Slop's remote service. They are opt-in alternatives, not local
23
+ checks in this workflow. Do not invoke them as part of an offline skill run without
24
+ the user's request. The npm `score` command continues to run locally.
25
+
21
26
  The science in one paragraph: detectors (and readers) key on the *post-training
22
27
  register* — text that sits at the most-probable phrasing, with uniform sentence
23
28
  rhythm, a few hundred over-represented style words, tidy template structure, and
@@ -0,0 +1,34 @@
1
+ export type Genre = "general" | "social" | "email" | "research" | "professional";
2
+ export type ResultStatus = "rewritten" | "rewritten_with_warnings" | "already_clear" | "unchanged_no_better_version" | "unchanged_verification_failed" | "unchanged_service_unavailable";
3
+ export interface WritingReport {
4
+ score: number; band: string; words: number; sentences: number; flaggedPhrases: number;
5
+ sentenceVariety: "natural" | "too even"; readability: "clear" | "needs work";
6
+ punctuation: { dashes: number; emoji: number; hashtags: number };
7
+ highWeightFlags: number;
8
+ shape: { measured: boolean; broetry: boolean; oneSentenceParagraphShare: number | null; longestFragmentRun: number | null };
9
+ register: {
10
+ measured: boolean; words: number; checked: number;
11
+ findings: Array<{ name: string; rate: number; budget: number; found: number; quote: string }>;
12
+ twoPartContrasts: number; announcements: number;
13
+ };
14
+ flags: Array<{ phrase: string; strength: number; issue: string; direction: string }>;
15
+ }
16
+ export interface PipelineResult {
17
+ text: string; status: ResultStatus; before: WritingReport; after: WritingReport;
18
+ scoreChange: number; factsPreserved: boolean; passedFinalChecks: boolean;
19
+ independentModelChecks: number; modelRequests: number; rolesCompleted: number;
20
+ finishingRounds: number; scorerVersion: string; durationMs: number; note: string;
21
+ }
22
+ export interface DeslopInput { text: string; genre?: Genre; audience?: string }
23
+ export interface DeslopOptions { signal?: AbortSignal; timeoutMs?: number; clientName?: string; clientVersion?: string }
24
+ export class DeslopError extends Error {
25
+ code: string; httpStatus?: number; rpcCode?: number; retryAfterSeconds?: number;
26
+ constructor(code: string, message: string, details?: Record<string, unknown>);
27
+ }
28
+ export const MCP_ENDPOINT: string;
29
+ export const GENRES: readonly Genre[];
30
+ export const RESULT_STATUSES: readonly ResultStatus[];
31
+ export function validateInput(input: DeslopInput): Required<Pick<DeslopInput, "text" | "genre">> & Pick<DeslopInput, "audience">;
32
+ export function validateResult(result: unknown): PipelineResult;
33
+ export function isApprovedResult(result: PipelineResult): boolean;
34
+ export function deslop(input: DeslopInput, options?: DeslopOptions): Promise<PipelineResult>;
@@ -0,0 +1,275 @@
1
+ // Shared hosted-MCP client. No filesystem, process, model or local-scoring work.
2
+ export const MCP_ENDPOINT = "https://mcp.zero-slop.ai/mcp";
3
+ export const GENRES = Object.freeze(["general", "social", "email", "research", "professional"]);
4
+ export const RESULT_STATUSES = Object.freeze([
5
+ "rewritten", "rewritten_with_warnings", "already_clear",
6
+ "unchanged_no_better_version", "unchanged_verification_failed",
7
+ "unchanged_service_unavailable",
8
+ ]);
9
+ const PROTOCOLS = ["2025-11-25", "2025-06-18"];
10
+ const MAX_REQUEST_BYTES = 128 * 1024;
11
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
12
+ const encoder = new TextEncoder();
13
+ // Zod's MCP string limits count Unicode code points, not UTF-16 units.
14
+ // Avoid allocating a character array, and stop as soon as the bound is crossed.
15
+ const withinLength = (value, maximum) => {
16
+ if (value.length <= maximum) return true;
17
+ let count = 0;
18
+ for (const _character of value) if (++count > maximum) return false;
19
+ return true;
20
+ };
21
+
22
+ export class DeslopError extends Error {
23
+ constructor(code, message, details = {}) {
24
+ super(message);
25
+ this.name = "DeslopError";
26
+ this.code = code;
27
+ Object.assign(this, details);
28
+ }
29
+ }
30
+
31
+ export function validateInput(input) {
32
+ if (!input || typeof input.text !== "string") {
33
+ throw new DeslopError("invalid_input", "Provide one UTF-8 draft as text.");
34
+ }
35
+ const text = input.text.trim();
36
+ const genre = input.genre === undefined ? "general" : input.genre;
37
+ if (!text || !withinLength(text, 20_000)) {
38
+ throw new DeslopError("invalid_input", "The draft must contain 1–20,000 Unicode code points after trimming.");
39
+ }
40
+ if (!GENRES.includes(genre)) {
41
+ throw new DeslopError("invalid_input", `Genre must be one of: ${GENRES.join(", ")}.`);
42
+ }
43
+ if (input.audience !== undefined && (typeof input.audience !== "string" || !withinLength(input.audience.trim(), 200))) {
44
+ throw new DeslopError("invalid_input", "Audience must be at most 200 Unicode code points.");
45
+ }
46
+ // Never forward filenames, private preferences or other caller properties.
47
+ return { text, genre, ...(input.audience?.trim() ? { audience: input.audience.trim() } : {}) };
48
+ }
49
+
50
+ const object = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
51
+ const number = (value, min, max = Infinity) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
52
+ const integer = (value) => Number.isSafeInteger(value) && value >= 0;
53
+ const string = (value, max = Infinity) => typeof value === "string" && withinLength(value, max);
54
+ const array = (value, max, check) => Array.isArray(value) && value.length <= max && value.every(check);
55
+
56
+ function validReport(value) {
57
+ return object(value) && number(value.score, 0, 100) && string(value.band, 80) && value.band.length > 0
58
+ && [value.words, value.sentences, value.flaggedPhrases, value.highWeightFlags].every(integer)
59
+ && ["natural", "too even"].includes(value.sentenceVariety) && ["clear", "needs work"].includes(value.readability)
60
+ && object(value.punctuation) && [value.punctuation.dashes, value.punctuation.emoji, value.punctuation.hashtags].every(integer)
61
+ && object(value.shape) && typeof value.shape.measured === "boolean" && typeof value.shape.broetry === "boolean"
62
+ && (value.shape.oneSentenceParagraphShare === null || number(value.shape.oneSentenceParagraphShare, 0, 1))
63
+ && (value.shape.longestFragmentRun === null || integer(value.shape.longestFragmentRun))
64
+ && object(value.register) && typeof value.register.measured === "boolean"
65
+ && [value.register.words, value.register.checked, value.register.twoPartContrasts, value.register.announcements].every(integer)
66
+ && array(value.register.findings, 1_000, (finding) => object(finding) && string(finding.name, 160)
67
+ && [finding.rate, finding.budget].every((item) => number(item, 0)) && integer(finding.found) && string(finding.quote, 1_000))
68
+ && array(value.flags, 5_000, (flag) => object(flag) && string(flag.phrase, 1_000)
69
+ && number(flag.strength, 0) && string(flag.issue, 2_000) && string(flag.direction, 2_000));
70
+ }
71
+
72
+ export function validateResult(result) {
73
+ if (!object(result) || !string(result.text) || !RESULT_STATUSES.includes(result.status)
74
+ || !validReport(result.before) || !validReport(result.after) || !number(result.scoreChange, -100, 100)
75
+ || typeof result.factsPreserved !== "boolean" || typeof result.passedFinalChecks !== "boolean"
76
+ || ![result.independentModelChecks, result.modelRequests, result.rolesCompleted, result.finishingRounds, result.durationMs].every(integer)
77
+ || result.modelRequests > 1 || !string(result.scorerVersion) || !string(result.note)) {
78
+ throw new DeslopError("invalid_response", "The MCP service returned an invalid structured result.");
79
+ }
80
+ // Return the original object, including additive fields; never rewrite its status.
81
+ return result;
82
+ }
83
+
84
+ export function isApprovedResult(result) {
85
+ try { validateResult(result); } catch { return false; }
86
+ return result?.factsPreserved === true && (
87
+ (result.status === "rewritten" && result.passedFinalChecks === true)
88
+ || (result.status === "already_clear" && result.modelRequests === 0
89
+ && result.scoreChange === 0 && result.before?.score === result.after?.score)
90
+ );
91
+ }
92
+
93
+ function messageFromJson(raw, id) {
94
+ let message;
95
+ try { message = JSON.parse(raw); } catch {
96
+ throw new DeslopError("invalid_response", "The MCP service returned malformed JSON.");
97
+ }
98
+ if (!object(message) || message.jsonrpc !== "2.0") {
99
+ throw new DeslopError("invalid_response", "The MCP service returned an invalid protocol message.");
100
+ }
101
+ if (message.id !== id) return null;
102
+ if (message.error !== undefined) {
103
+ const rpcCode = Number.isSafeInteger(message.error?.code) ? message.error.code : undefined;
104
+ throw new DeslopError("protocol_error", "The MCP service rejected the request.", { rpcCode });
105
+ }
106
+ if (!object(message.result)) {
107
+ throw new DeslopError("invalid_response", "The MCP service returned no result.");
108
+ }
109
+ return message.result;
110
+ }
111
+
112
+ async function readReply(response, id, signal) {
113
+ const type = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
114
+ if (!["application/json", "text/event-stream"].includes(type) || !response.body) {
115
+ void response.body?.cancel().catch(() => {});
116
+ throw new DeslopError("invalid_response", "The MCP service returned an unsupported response type.");
117
+ }
118
+ const reader = response.body.getReader();
119
+ const decoder = new TextDecoder("utf-8", { fatal: true });
120
+ let size = 0;
121
+ let buffer = "";
122
+ const stop = () => { void reader.cancel().catch(() => {}); };
123
+ signal.addEventListener("abort", stop, { once: true });
124
+ try {
125
+ while (true) {
126
+ signal.throwIfAborted();
127
+ const { value, done } = await reader.read();
128
+ signal.throwIfAborted();
129
+ if (value) {
130
+ size += value.byteLength;
131
+ if (size > MAX_RESPONSE_BYTES) throw new DeslopError("invalid_response", "The MCP response exceeded the size limit.");
132
+ }
133
+ buffer += decoder.decode(value, { stream: !done });
134
+ if (type === "text/event-stream") {
135
+ let boundary;
136
+ while ((boundary = /\r\n\r\n|\n\n|\r\r/.exec(buffer))) {
137
+ const event = buffer.slice(0, boundary.index);
138
+ buffer = buffer.slice(boundary.index + boundary[0].length);
139
+ const data = event.split(/\r\n|\n|\r/).filter((line) => line.startsWith("data:"))
140
+ .map((line) => line.slice(5).replace(/^ /, "")).join("\n");
141
+ if (!data) continue;
142
+ const reply = messageFromJson(data, id);
143
+ if (reply) return reply;
144
+ }
145
+ } else if (done) {
146
+ const reply = messageFromJson(buffer, id);
147
+ if (reply) return reply;
148
+ }
149
+ if (done) throw new DeslopError("invalid_response", "The MCP response ended before the matching result arrived.");
150
+ }
151
+ } catch (error) {
152
+ if (signal.aborted) throw signal.reason;
153
+ if (error instanceof DeslopError) throw error;
154
+ throw new DeslopError("invalid_response", "The MCP response could not be read.");
155
+ } finally {
156
+ signal.removeEventListener("abort", stop);
157
+ stop();
158
+ reader.releaseLock();
159
+ }
160
+ }
161
+
162
+ /** Call the existing hosted pipeline once. Cancellation cannot guarantee server work stops. */
163
+ export async function deslop(input, options = {}) {
164
+ const args = validateInput(input);
165
+ const timeoutMs = options.timeoutMs ?? 75_000;
166
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) {
167
+ throw new DeslopError("invalid_input", "Timeout must be between 1 and 300,000 milliseconds.");
168
+ }
169
+ const clientName = options.clientName ?? "zero-slop";
170
+ const clientVersion = options.clientVersion ?? "unknown";
171
+ if (!/^[a-zA-Z0-9._-]{1,64}$/.test(clientName) || !/^[a-zA-Z0-9.+_-]{1,64}$/.test(clientVersion)) {
172
+ throw new DeslopError("invalid_input", "Client name and version must be bounded application metadata.");
173
+ }
174
+ const toolBody = { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "deslop", arguments: args } };
175
+ if (encoder.encode(JSON.stringify(toolBody)).byteLength > MAX_REQUEST_BYTES) {
176
+ throw new DeslopError("invalid_input", "The encoded MCP request exceeds 128 KiB.");
177
+ }
178
+ const controller = new AbortController();
179
+ const cancel = () => controller.abort(new DeslopError("cancelled", "Cancelled locally; hosted processing may still finish."));
180
+ if (options.signal?.aborted) cancel();
181
+ else options.signal?.addEventListener("abort", cancel, { once: true });
182
+ const timer = setTimeout(() => controller.abort(new DeslopError("timeout", "The hosted request timed out; its outcome is unknown. No retry was sent.")), timeoutMs);
183
+ let protocolVersion = PROTOCOLS[0];
184
+ let session;
185
+ let toolPending = false;
186
+ // Hosted CLI attribution only: no identifier, machine data, or extra request.
187
+ // Other integrations retain their existing clientInfo without posing as CLI.
188
+ const cliMajorVersion = clientVersion.match(/^(\d{1,4})(?:\.|$)/)?.[1] ?? "unknown";
189
+ const headers = () => ({
190
+ "content-type": "application/json", accept: "application/json, text/event-stream",
191
+ "cache-control": "no-store", "mcp-protocol-version": protocolVersion,
192
+ ...(clientName === "zero-slop-cli" ? { "user-agent": `zero-slop-cli/${cliMajorVersion}` } : {}),
193
+ ...(session ? { "mcp-session-id": session } : {}),
194
+ });
195
+ // A dropped HTTP connection is not an MCP cancellation notification. Request it
196
+ // separately, without replaying the tool, but bound this best-effort cleanup.
197
+ const notifyCancellation = () => {
198
+ if (!toolPending) return;
199
+ const cleanup = new AbortController();
200
+ const cleanupTimer = setTimeout(() => cleanup.abort(), 250);
201
+ void fetch(MCP_ENDPOINT, {
202
+ method: "POST", redirect: "manual", signal: cleanup.signal, headers: headers(),
203
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 2, reason: "Client cancelled" } }),
204
+ }).then((response) => response.body?.cancel()).catch(() => {}).finally(() => clearTimeout(cleanupTimer));
205
+ };
206
+ controller.signal.addEventListener("abort", notifyCancellation, { once: true });
207
+ async function rpc(body, notification = false) {
208
+ controller.signal.throwIfAborted();
209
+ const response = await fetch(MCP_ENDPOINT, {
210
+ method: "POST", redirect: "manual", signal: controller.signal, headers: headers(), body: JSON.stringify(body),
211
+ });
212
+ controller.signal.throwIfAborted();
213
+ if (!response.ok) {
214
+ void response.body?.cancel().catch(() => {});
215
+ const retry = response.headers.get("retry-after");
216
+ const retryAfterSeconds = retry && /^\d{1,5}$/.test(retry) ? Number(retry) : undefined;
217
+ throw new DeslopError("http_error", response.status === 429
218
+ ? "Zero Slop is busy or at a usage limit. Please wait before trying again. No retry was sent."
219
+ : `The MCP service returned HTTP ${response.status}. No retry was sent.`, {
220
+ httpStatus: response.status, ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
221
+ });
222
+ }
223
+ if (notification) {
224
+ void response.body?.cancel().catch(() => {});
225
+ return;
226
+ }
227
+ if (body.method === "initialize") {
228
+ session = response.headers.get("mcp-session-id");
229
+ if (session && !/^[\x21-\x7e]{1,512}$/.test(session)) {
230
+ void response.body?.cancel().catch(() => {});
231
+ throw new DeslopError("invalid_response", "The MCP service returned an invalid session identifier.");
232
+ }
233
+ }
234
+ return readReply(response, body.id, controller.signal);
235
+ }
236
+ try {
237
+ const initialized = await rpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: {
238
+ protocolVersion, capabilities: {}, clientInfo: { name: clientName, version: clientVersion },
239
+ } });
240
+ if (!PROTOCOLS.includes(initialized.protocolVersion)) {
241
+ throw new DeslopError("unsupported_protocol", "The MCP service negotiated an unsupported protocol version.");
242
+ }
243
+ protocolVersion = initialized.protocolVersion;
244
+ await rpc({ jsonrpc: "2.0", method: "notifications/initialized" }, true);
245
+ toolPending = true;
246
+ const reply = await rpc(toolBody);
247
+ toolPending = false;
248
+ if (reply.isError !== undefined && typeof reply.isError !== "boolean") {
249
+ throw new DeslopError("invalid_response", "The MCP service returned an invalid tool result.");
250
+ }
251
+ if (reply.isError === true) {
252
+ const gate = reply._meta?.["zero-slop/error"];
253
+ if (gate && (gate.code === "usage_limit" || gate.code === "budget_unavailable") &&
254
+ gate.status === (gate.code === "usage_limit" ? 429 : 503)) {
255
+ const retry = gate.retryAfterSeconds;
256
+ throw new DeslopError(gate.code, gate.code === "usage_limit"
257
+ ? "Hosted editing is temporarily busy or at its free usage limit. Please wait before trying again. Keep your source; no retry was sent."
258
+ : "Hosted capacity could not be checked. Please try again later. Keep your source; no retry was sent.", {
259
+ httpStatus: gate.status,
260
+ ...(Number.isInteger(retry) && retry > 0 && retry <= 86_400 ? { retryAfterSeconds: retry } : {}),
261
+ });
262
+ }
263
+ throw new DeslopError("tool_error", "Zero Slop could not return a safely checked result. The source was not changed locally.");
264
+ }
265
+ return validateResult(reply.structuredContent);
266
+ } catch (error) {
267
+ if (controller.signal.aborted) throw controller.signal.reason;
268
+ if (error instanceof DeslopError) throw error;
269
+ throw new DeslopError("network_error", "The hosted MCP request failed. Its outcome may be unknown; no retry was sent.");
270
+ } finally {
271
+ clearTimeout(timer);
272
+ options.signal?.removeEventListener("abort", cancel);
273
+ controller.signal.removeEventListener("abort", notifyCancellation);
274
+ }
275
+ }
package/bin/zero-slop.mjs CHANGED
@@ -5,11 +5,13 @@
5
5
  // mirrors rather than people. This is the executable half: it installs the same
6
6
  // runtime the tarball already carries, and runs the scorer without a checkout.
7
7
 
8
- import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
8
+ import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
9
+ import { constants } from "node:fs";
9
10
  import { spawn } from "node:child_process";
10
11
  import { homedir } from "node:os";
11
12
  import { dirname, join, parse, resolve } from "node:path";
12
13
  import { fileURLToPath } from "node:url";
14
+ import { deslop, DeslopError, isApprovedResult, validateInput } from "./lib/deslop.mjs";
13
15
 
14
16
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
15
17
  const PAYLOAD = ["SKILL.md", "references", "scripts", "data"];
@@ -30,11 +32,13 @@ async function version() {
30
32
  }
31
33
 
32
34
  function usage(v) {
33
- return `zero-slop ${v} — score AI-sounding prose 0-100 and edit it out.
35
+ return `zero-slop ${v} — score locally or edit through the hosted Zero Slop MCP.
34
36
 
35
37
  Usage
36
38
  npx zero-slop install [--harness <name>] [--dir <path>] [--force]
37
- npx zero-slop score <file>... [-- <slopscore flags>]
39
+ npx zero-slop score <file|-> [-- <slopscore flags>]
40
+ npx zero-slop deslop <file|-> [--genre <name>] [--audience <reader>] [--json]
41
+ [--require-approved] [--timeout <seconds>]
38
42
  npx zero-slop where
39
43
  npx zero-slop --version
40
44
 
@@ -43,11 +47,27 @@ Install targets
43
47
  --dir install into an explicit directory instead
44
48
  --force replace a verified Zero Slop installation
45
49
 
50
+ Hosted editing (deslop)
51
+ Sends only the explicit draft, genre and audience to https://mcp.zero-slop.ai/mcp.
52
+ Zero Slop does not store drafts or rewrites; aggregate usage metrics are recorded.
53
+ Requires Node.js 22+, network access, and no Python or API key. No automatic retry.
54
+ One UTF-8 file or stdin (-); 1–20,000 Unicode code points, audience at most 200.
55
+ --genre general (default), social, email, research, professional
56
+ --json full structured MCP result on stdout; notices stay on stderr
57
+ --require-approved exit 3 unless already clear or a checked rewrite; keep the result
58
+ --timeout whole remote request timeout in seconds (default: 75; maximum: 300)
59
+ Text mode prints the returned draft on stdout and the review summary on stderr.
60
+ No files are changed. A timeout or cancellation may not stop hosted processing.
61
+ Exits: 0 valid result, 1 request failure, 2 invalid input, 3 approval gate,
62
+ 124 timeout, 130 interrupted, 143 terminated.
63
+
46
64
  Examples
47
65
  npx zero-slop install # ~/.claude/skills/zero-slop
48
66
  npx zero-slop install --harness codex
49
67
  npx zero-slop score draft.md
50
68
  npx zero-slop score drafts/ -- --batch --gate 25
69
+ npx zero-slop deslop draft.md --genre email
70
+ npx zero-slop deslop - --json < draft.md
51
71
 
52
72
  Docs: https://zero-slop.ai Source: https://github.com/manavmishra/ZeroSlop`;
53
73
  }
@@ -193,12 +213,148 @@ function runScorer(args) {
193
213
  console.error(err.message);
194
214
  resolvePromise(1);
195
215
  });
196
- child.on("close", (code) => resolvePromise(code ?? 0));
216
+ child.on("close", (code, signal) => resolvePromise(code ?? (signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1)));
197
217
  });
198
218
  }
199
219
 
220
+ function parseDeslopArgs(argv) {
221
+ const values = {};
222
+ const files = [];
223
+ const allowedValues = new Set(["--genre", "--audience", "--timeout"]);
224
+ let positionalOnly = false;
225
+ for (let index = 0; index < argv.length; index += 1) {
226
+ const argument = argv[index];
227
+ if (positionalOnly) { files.push(argument); continue; }
228
+ if (argument === "--") { positionalOnly = true; continue; }
229
+ const [flag, ...attached] = argument.split("=");
230
+ if (allowedValues.has(flag)) {
231
+ if (Object.hasOwn(values, flag)) throw new DeslopError("invalid_input", `Duplicate option: ${flag}`);
232
+ const value = attached.length ? attached.join("=") : argv[++index];
233
+ if (value === undefined || value.startsWith("--")) throw new DeslopError("invalid_input", `${flag} needs a value.`);
234
+ values[flag] = value;
235
+ } else if (argument === "--json" || argument === "--require-approved") {
236
+ if (values[argument]) throw new DeslopError("invalid_input", `Duplicate option: ${argument}`);
237
+ values[argument] = true;
238
+ } else if (argument.startsWith("-") && argument !== "-") {
239
+ throw new DeslopError("invalid_input", "Unknown deslop option. See: zero-slop deslop --help");
240
+ } else files.push(argument);
241
+ }
242
+ if (files.length !== 1) throw new DeslopError("invalid_input", "deslop needs exactly one file or '-' for stdin; directories and batches are not uploaded.");
243
+ const seconds = values["--timeout"] === undefined ? 75 : Number(values["--timeout"]);
244
+ const timeoutMs = seconds * 1_000;
245
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) {
246
+ throw new DeslopError("invalid_input", "--timeout needs 0.001–300 seconds.");
247
+ }
248
+ return { file: files[0], genre: values["--genre"], audience: values["--audience"],
249
+ json: Boolean(values["--json"]), requireApproved: Boolean(values["--require-approved"]), timeoutMs };
250
+ }
251
+
252
+ async function readDraft(file, signal) {
253
+ const maximum = 128 * 1024;
254
+ let bytes;
255
+ if (file === "-") {
256
+ const chunks = [];
257
+ let size = 0;
258
+ const stop = () => process.stdin.destroy(signal.reason);
259
+ signal.addEventListener("abort", stop, { once: true });
260
+ try {
261
+ signal.throwIfAborted();
262
+ for await (const chunk of process.stdin) {
263
+ signal.throwIfAborted();
264
+ size += chunk.length;
265
+ if (size > maximum) throw new DeslopError("invalid_input", "The input exceeds 128 KiB; nothing was uploaded.");
266
+ chunks.push(chunk);
267
+ }
268
+ bytes = Buffer.concat(chunks);
269
+ } finally { signal.removeEventListener("abort", stop); }
270
+ } else {
271
+ let handle;
272
+ try {
273
+ // Nonblocking open plus fstat rejects directories, devices and named pipes.
274
+ handle = await open(file, constants.O_RDONLY | constants.O_NONBLOCK);
275
+ const info = await handle.stat();
276
+ if (!info.isFile() || info.size > maximum) throw new DeslopError("invalid_input", "Provide one regular UTF-8 file no larger than 128 KiB; nothing was uploaded.");
277
+ // Keep the read bounded even if a regular file grows after fstat.
278
+ const buffer = Buffer.alloc(maximum + 1);
279
+ let size = 0;
280
+ while (size < buffer.length) {
281
+ signal.throwIfAborted();
282
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, null);
283
+ if (!bytesRead) break;
284
+ size += bytesRead;
285
+ }
286
+ if (size > maximum) throw new DeslopError("invalid_input", "The input exceeds 128 KiB; nothing was uploaded.");
287
+ bytes = buffer.subarray(0, size);
288
+ } finally { await handle?.close(); }
289
+ }
290
+ signal.throwIfAborted();
291
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch {
292
+ throw new DeslopError("invalid_input", "The draft must be valid UTF-8; nothing was uploaded.");
293
+ }
294
+ }
295
+
296
+ async function runDeslop(argv, v) {
297
+ const controller = new AbortController();
298
+ let interrupted;
299
+ let parsed;
300
+ const json = argv.slice(0, argv.indexOf("--") < 0 ? undefined : argv.indexOf("--")).includes("--json");
301
+ const stop = (signal) => {
302
+ interrupted = signal;
303
+ controller.abort(new DeslopError("cancelled", "Cancelled locally; hosted processing may still finish."));
304
+ };
305
+ const onInterrupt = () => stop("SIGINT");
306
+ const onTerminate = () => stop("SIGTERM");
307
+ process.once("SIGINT", onInterrupt);
308
+ process.once("SIGTERM", onTerminate);
309
+ try {
310
+ parsed = parseDeslopArgs(argv);
311
+ let text;
312
+ try { text = await readDraft(parsed.file, controller.signal); } catch (error) {
313
+ if (error instanceof DeslopError || controller.signal.aborted) throw error;
314
+ throw new DeslopError("invalid_input", "The input file could not be read; nothing was uploaded.");
315
+ }
316
+ const input = validateInput({ text, genre: parsed.genre, audience: parsed.audience });
317
+ console.error("Hosted editing: sending this draft to mcp.zero-slop.ai. Drafts and rewrites are not stored; aggregate usage metrics are recorded.");
318
+ const result = await deslop(input, { signal: controller.signal, timeoutMs: parsed.timeoutMs,
319
+ clientName: "zero-slop-cli", clientVersion: v });
320
+ controller.signal.throwIfAborted();
321
+ process.stdout.write(parsed.json ? `${JSON.stringify(result)}\n` : result.text);
322
+ if (!parsed.json) {
323
+ console.error(`Status: ${result.status}. Writing score: ${result.before.score} before, ${result.after.score} after. Lower is better.`);
324
+ console.error(`Facts preserved: ${result.factsPreserved ? "yes" : "not confirmed"}. Final checks: ${result.passedFinalChecks ? "passed" : result.status === "already_clear" ? "not needed; already clear" : "did not all pass"}.`);
325
+ // Notes can contain prose supplied by the service. Render them as text,
326
+ // without allowing terminal-control sequences to execute.
327
+ console.error(result.note.replace(/[\u0000-\u001f\u007f-\u009f]/g, " "));
328
+ }
329
+ return parsed.requireApproved && !isApprovedResult(result) ? 3 : 0;
330
+ } catch (error) {
331
+ const failure = controller.signal.aborted ? controller.signal.reason
332
+ : error instanceof DeslopError ? error : new DeslopError("request_failed", "The request could not be completed.");
333
+ const code = interrupted ? (interrupted === "SIGINT" ? 130 : 143)
334
+ : failure.code === "timeout" ? 124 : failure.code === "invalid_input" ? 2 : 1;
335
+ const details = { code: failure.code, message: failure.message,
336
+ ...(failure.httpStatus !== undefined ? { httpStatus: failure.httpStatus } : {}),
337
+ ...(failure.rpcCode !== undefined ? { rpcCode: failure.rpcCode } : {}),
338
+ ...(failure.retryAfterSeconds !== undefined ? { retryAfterSeconds: failure.retryAfterSeconds } : {}) };
339
+ if (json) process.stdout.write(`${JSON.stringify({ error: details })}\n`);
340
+ console.error(failure.message);
341
+ if (failure.retryAfterSeconds !== undefined) console.error(`Retry-After: ${failure.retryAfterSeconds} seconds. No retry was sent.`);
342
+ return code;
343
+ } finally {
344
+ process.removeListener("SIGINT", onInterrupt);
345
+ process.removeListener("SIGTERM", onTerminate);
346
+ }
347
+ }
348
+
200
349
  async function main() {
201
350
  const argv = process.argv.slice(2);
351
+ // Parse this command separately: no install flags or scorer passthrough can
352
+ // accidentally become a remote-upload option.
353
+ if (argv[0] === "deslop") {
354
+ const v = await version();
355
+ if (argv.length === 2 && ["--help", "-h"].includes(argv[1])) { console.log(usage(v)); return 0; }
356
+ return runDeslop(argv.slice(1), v);
357
+ }
202
358
  const { flags, rest, passthrough } = parseArgs(argv);
203
359
  const command = rest[0];
204
360
  const v = await version();
@@ -236,8 +392,8 @@ async function main() {
236
392
  }
237
393
 
238
394
  main()
239
- .then((code) => process.exit(code))
395
+ .then((code) => { process.exitCode = code; })
240
396
  .catch((err) => {
241
397
  console.error(err.message);
242
- process.exit(1);
398
+ process.exitCode = 1;
243
399
  });
package/docs/cli.md ADDED
@@ -0,0 +1,95 @@
1
+ # Zero Slop CLI
2
+
3
+ Use `deslop` for hosted editing with MCP parity. Use `score` for offline checks.
4
+
5
+ ```sh
6
+ npm install --global zero-slop@2.10.1
7
+ zero-slop deslop draft.md --genre professional
8
+ zero-slop deslop - --genre email < draft.txt
9
+ zero-slop deslop draft.md --json --require-approved
10
+ zero-slop score draft.md -- --json
11
+ ```
12
+
13
+ Use Node.js 22 or newer for hosted editing. Offline scoring also requires Python 3.
14
+ The npm package includes the skill and its local checks; no separate model ships.
15
+
16
+ On macOS, [Homebrew](https://github.com/manavmishra/homebrew-zero-slop) installs
17
+ the same npm CLI plus Node and Python:
18
+
19
+ ```sh
20
+ brew install manavmishra/zero-slop/zero-slop
21
+ ```
22
+
23
+ The tap pins a tested npm release; a new npm version may arrive before its
24
+ Homebrew update. Linux Homebrew has not been tested.
25
+
26
+ `deslop` reads exactly one file or standard input, sends the draft to
27
+ `https://mcp.zero-slop.ai/mcp`, and writes the returned text to standard output.
28
+ It never edits a source file in place. Warnings go to standard error. Capture
29
+ standard output only when you intend to save it.
30
+
31
+ ## Options and results
32
+
33
+ | Option | Purpose |
34
+ |---|---|
35
+ | `--genre` | `general`, `social`, `email`, `research`, or `professional` |
36
+ | `--audience` | Intended reader or destination, up to 200 Unicode code points |
37
+ | `--json` | Full MCP result, including all check fields and status |
38
+ | `--require-approved` | Exit nonzero if the result needs review |
39
+ | `--timeout` | Remote request deadline in seconds: 75 by default, up to 300 |
40
+
41
+ The draft limit is 20,000 Unicode code points after trimming. Directory uploads,
42
+ automatic chunking, and automatic retries are not supported. Only the draft and
43
+ supplied genre/audience are transmitted; filenames and private learning profiles
44
+ stay local. See the [remote-service privacy boundary](../SECURITY.md#remote-service-boundary).
45
+
46
+ Inspect the [six result statuses](rest-api.md#read-the-result). In particular,
47
+ `rewritten_with_warnings` is an edit to review, not an approved result.
48
+ `already_clear` returns the original without an editing-model request.
49
+
50
+ | Exit code | Meaning |
51
+ |---|---|
52
+ | `0` | A valid result was returned; inspect its status before using it |
53
+ | `1` | Transport or service failure |
54
+ | `2` | Invalid input or options |
55
+ | `3` | `--require-approved` rejected the result; the result is still printed |
56
+ | `124` | Remote request timed out |
57
+ | `130` / `143` | Interrupted / terminated |
58
+
59
+ A timeout or cancellation may not stop hosted processing. The CLI does not replay
60
+ the request. Review any returned status or error before choosing to send the draft again.
61
+
62
+ Hosted editing shares its free allowance with MCP, REST, and `/try/`. A
63
+ `usage_limit` error exits `1`; JSON errors include `httpStatus: 429` and, when
64
+ provided, `retryAfterSeconds`. Wait at least that long before a manual retry.
65
+ `budget_unavailable` (`503`) means capacity could not be checked, so no new model
66
+ request was started. Offline scoring does not consume this allowance.
67
+
68
+ Hosted calls are included in aggregate service metrics: channel, result status,
69
+ model attempts, and latency. A fixed CLI-family/major-version header identifies
70
+ the entry channel on the requests already being sent; no extra tracking request
71
+ is made. It does not identify a person or installation. Offline commands send no
72
+ analytics. See the [security policy](../SECURITY.md) for the complete field list.
73
+
74
+ ## Offline scoring
75
+
76
+ ```sh
77
+ zero-slop score draft.md -- --json
78
+ zero-slop score - -- --json < draft.txt
79
+ zero-slop score docs/ -- --batch --json --gate 25
80
+ ```
81
+
82
+ The scorer does not transmit drafts. A numerical gate fails when the score exceeds
83
+ its threshold; equality passes. Its score measures writing patterns, not authorship
84
+ or factual truth. Hosted editorial approval includes additional checks and should
85
+ not be inferred from the number alone.
86
+
87
+ ## Install the portable skill
88
+
89
+ ```sh
90
+ zero-slop install
91
+ ```
92
+
93
+ The installed skill runs inside a compatible AI assistant. Its editing follows that
94
+ assistant's privacy settings. Installing the skill does not send your writing to
95
+ the hosted service.
@@ -0,0 +1,182 @@
1
+ # Zero Slop REST API
2
+
3
+ `POST https://mcp.zero-slop.ai/v1/deslop` edits one draft through the same pipeline
4
+ as the hosted MCP `deslop` tool. It returns the same 14 fields, including the edit,
5
+ writing scores, source-check result, and review status.
6
+
7
+ The [OpenAPI 3.1.2 contract](https://mcp.zero-slop.ai/openapi.json) is generated
8
+ from the schemas used to validate MCP and REST requests and results. Import it
9
+ into an OpenAPI-compatible client or documentation tool.
10
+
11
+ Use the [API sandbox](https://zero-slop.ai/sandbox/) to inspect a simulated result
12
+ before sending a draft. Its live mode requires an explicit request and uses this
13
+ same endpoint.
14
+
15
+ ## Send a draft
16
+
17
+ ```sh
18
+ curl --fail-with-body --max-time 75 --include https://mcp.zero-slop.ai/v1/deslop \
19
+ -H 'Content-Type: application/json' \
20
+ --data '{"text":"It is important to note that Maya owns the pricing review. The team will decide on Friday.","genre":"professional","audience":"The product team"}'
21
+ ```
22
+
23
+ | Field | Accepted value |
24
+ |---|---|
25
+ | `text` | Required, nonempty string; up to 20,000 Unicode code points after trimming |
26
+ | `genre` | `general` (default), `social`, `email`, `research`, or `professional` |
27
+ | `audience` | Optional string, up to 200 Unicode code points after trimming |
28
+
29
+ Lengths count Unicode code points; a combined emoji or accented character can contain more than one.
30
+ Send UTF-8 JSON in the body, never a URL query. The body limit is 128 KiB, including
31
+ JSON escaping. Compressed bodies are not accepted. Unknown properties are ignored.
32
+
33
+ Call from your application server. Browser access is enabled only for the Zero Slop
34
+ site at `https://zero-slop.ai` and `https://www.zero-slop.ai`; other sites should
35
+ call from their servers. CORS is a browser policy, not authentication.
36
+
37
+ Access is free, without an API key. REST and MCP share capacity and usage limits,
38
+ including a daily hosted allowance and a short client cooldown. The allowance can
39
+ run out; it does not reserve capacity for an application. Availability is best
40
+ effort, with no uptime SLA. Offline skill checks and CLI scoring stay free and do
41
+ not consume this hosted allowance.
42
+
43
+ Current safety limits allow at most five model calls per client network per UTC
44
+ day and two per minute, shared across REST, MCP, CLI editing, and `/try/`.
45
+ Shared networks can reach those limits together. A separate global limit reserves
46
+ up to 8,000 estimated Workers AI neurons per UTC day before inference; it can
47
+ stop calls before the client limit is reached. Reservations are conservative and
48
+ are not refunded after timeouts. New model calls pause during the last minute
49
+ before the UTC reset. These are capacity ceilings, not a guaranteed allocation.
50
+
51
+ The service records aggregate REST call counts, result categories, model attempts,
52
+ quota rejections, and latency. Drafts, rewrites, IP addresses, email addresses and
53
+ stable user identifiers are excluded from this telemetry. Calls are not unique
54
+ users, and model attempts are not billable token counts. See the
55
+ [security policy](../SECURITY.md) for the field list and retention.
56
+
57
+ ## Working language examples
58
+
59
+ The [example directory](../integrations/api-examples/README.md) contains runnable
60
+ clients with 75-second timeouts, structured error handling, and an explicit check
61
+ before the returned text can pass to an automated step.
62
+
63
+ | Language | Source | HTTP client |
64
+ |---|---|---|
65
+ | curl | [Shell example](../integrations/api-examples/curl/deslop.sh) | curl; jq reads the result |
66
+ | JavaScript | [Node.js example](../integrations/api-examples/javascript/deslop.mjs) | Built-in `fetch` |
67
+ | TypeScript | [Typed Node.js example](../integrations/api-examples/typescript/deslop.ts) | Built-in `fetch` |
68
+ | Python | [Python example](../integrations/api-examples/python/deslop.py) | Standard-library `urllib.request` |
69
+ | Java | [Java example](../integrations/api-examples/java/src/main/java/Deslop.java) | JDK `HttpClient`; Jackson for JSON |
70
+ | C# | [.NET example](../integrations/api-examples/csharp/Program.cs) | `HttpClient` and `System.Text.Json` |
71
+ | Go | [Go example](../integrations/api-examples/go/main.go) | Standard-library `net/http` |
72
+ | Rust | [Rust example](../integrations/api-examples/rust/src/main.rs) | reqwest and serde_json |
73
+
74
+ Each example reads one request object from stdin, sends one POST, and preserves
75
+ the complete JSON response. Exit `0` means the approval condition below passed;
76
+ exit `3` means review is required. HTTP and transport failures exit `1`. Check
77
+ the exit code before extracting `text`; an HTTP `200` alone is insufficient.
78
+ These are examples to adapt within an application, not separate SDKs.
79
+
80
+ ## Read the result
81
+
82
+ HTTP `200` means the pipeline returned a result. It does not mean the edit passed
83
+ every check.
84
+
85
+ | `status` | What to do |
86
+ |---|---|
87
+ | `rewritten` | Review the edit; the model edit passed the local writing and source checks |
88
+ | `already_clear` | Keep the original; no editing-model call was needed |
89
+ | `rewritten_with_warnings` | Read `note` and review the result before using it |
90
+ | `unchanged_no_better_version` | Keep the original; no better version was selected |
91
+ | `unchanged_verification_failed` | Keep the original; proposed edits failed source checks |
92
+ | `unchanged_service_unavailable` | Keep the original; the model did not yield a usable edit |
93
+
94
+ The successful response contains these 14 fields:
95
+
96
+ | Field | Meaning |
97
+ |---|---|
98
+ | `text` | Returned draft, which may be unchanged |
99
+ | `status` | One of the six outcomes above |
100
+ | `before`, `after` | Writing reports: score, flagged phrases, readability, rhythm, punctuation, layout, and register checks |
101
+ | `scoreChange` | After score minus before score |
102
+ | `factsPreserved` | Whether tracked source details survived the checks |
103
+ | `passedFinalChecks` | Whether the rewritten text passed the final checks |
104
+ | `independentModelChecks` | Count of independent model checks; currently zero |
105
+ | `modelRequests` | Editing-model requests made, zero or one |
106
+ | `rolesCompleted` | Completed pipeline responsibilities |
107
+ | `finishingRounds` | Additional local finishing rounds |
108
+ | `scorerVersion` | Deployed Zero Slop release |
109
+ | `durationMs` | Server processing time in milliseconds |
110
+ | `note` | Review guidance, including any limitations |
111
+
112
+ `scoreChange` is after minus before, so a negative value means the measured score fell.
113
+ The writing score is not a probability that AI wrote the draft.
114
+
115
+ For an automation that must stop on a warning, use this condition:
116
+
117
+ ```js
118
+ const approved = result.factsPreserved === true && (
119
+ (result.status === "already_clear" && result.modelRequests === 0 &&
120
+ result.scoreChange === 0 && result.before.score === result.after.score) ||
121
+ (result.status === "rewritten" && result.passedFinalChecks === true)
122
+ );
123
+ ```
124
+
125
+ `already_clear` has `passedFinalChecks: false` because editing-model checks were not
126
+ needed. Do not reject it solely on that boolean. Source checks protect tracked
127
+ details; they cannot certify factual truth or every change in meaning. Human review
128
+ remains necessary before publication.
129
+
130
+ ## Errors and retries
131
+
132
+ Errors use `application/problem+json` with `type`, `title`, `status`, `detail`,
133
+ `code`, and `requestId`. Their details do not echo drafts, model responses, or secrets.
134
+
135
+ | HTTP status | Meaning |
136
+ |---|---|
137
+ | `400` | Invalid JSON, UTF-8, declared length, or input fields |
138
+ | `403` | `forbidden_origin` or `forbidden_preflight`; use an allowed first-party browser request or call from your server |
139
+ | `405` | Wrong method; use `POST` |
140
+ | `408` | The request body did not arrive within 10 seconds |
141
+ | `413` | The JSON body exceeds 128 KiB |
142
+ | `415` | Unsupported media type or compression |
143
+ | `429` | `capacity_limit` or `usage_limit`; respect `Retry-After` |
144
+ | `503` | `budget_unavailable` if the usage gate could not complete, or `service_unavailable` if no safely scored result was available |
145
+
146
+ For example, a usage-limit response has this shape; values here are illustrative:
147
+
148
+ ```json
149
+ {
150
+ "type": "about:blank",
151
+ "title": "Too Many Requests",
152
+ "status": 429,
153
+ "detail": "The free hosted allowance is currently exhausted. Try again after the indicated delay.",
154
+ "code": "usage_limit",
155
+ "requestId": "11111111-1111-4111-8111-111111111111"
156
+ }
157
+ ```
158
+
159
+ The `Retry-After` response header gives the minimum delay in seconds. Read it
160
+ from the header rather than parsing `detail`. Keep the problem code and request
161
+ ID for troubleshooting without logging the draft or result.
162
+
163
+ Allow 75 seconds on the client. The service makes at most one editing-model request;
164
+ scoring and source checks add their own bounded work. A client timeout or cancellation
165
+ does not guarantee upstream work stopped. Do not retry automatically after an uncertain
166
+ outcome. If a user chooses to retry a `429`, wait at least the `Retry-After` delay.
167
+
168
+ The API does not store drafts or results for idempotency. Repeated POSTs are separate
169
+ calls, may repeat work, and are not guaranteed to return identical text.
170
+
171
+ ## Privacy and versioning
172
+
173
+ The draft is sent to Zero Slop's hosted service and processed in memory. The upstream
174
+ editing service must confirm `stored: false`; drafts and rewrites are not cached or logged by this
175
+ service. Aggregate operational counts and short-lived daily hashed client counters
176
+ support usage limits and abuse controls. See [SECURITY.md](../SECURITY.md) and
177
+ the [hosted-service terms](https://zero-slop.ai/terms/).
178
+
179
+ `/v1` identifies the HTTP contract. `scorerVersion` identifies the deployed Zero Slop
180
+ release. Clients should accept new optional response fields and treat any unrecognized
181
+ result status as requiring review. The OpenAPI document is served by the same Worker
182
+ as the API, so its version follows the deployment.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.9.2",
3
+ "version": "2.10.1",
4
4
  "description": "Find and fix canned AI writing while preserving the facts, voice, and format.",
5
5
  "mcpServers": {
6
6
  "zero-slop": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.9.2",
4
- "description": "An Agent Skill that scores AI-sounding prose locally, guides the host AI through an editorial rewrite, and checks changed source details. The local tools run offline with zero dependencies.",
3
+ "version": "2.10.1",
4
+ "description": "An editing skill, offline writing checks, and a CLI for the hosted Zero Slop MCP pipeline. Find stock AI phrasing, rewrite, and check source details.",
5
5
  "bin": {
6
6
  "zero-slop": "bin/zero-slop.mjs"
7
7
  },
@@ -21,6 +21,9 @@
21
21
  "ai-writing",
22
22
  "writing-tools",
23
23
  "editing",
24
+ "cli",
25
+ "mcp",
26
+ "rest-api",
24
27
  "prose"
25
28
  ],
26
29
  "homepage": "https://zero-slop.ai",
@@ -41,6 +44,9 @@
41
44
  "scripts/",
42
45
  "data/",
43
46
  "README.md",
47
+ "docs/cli.md",
48
+ "docs/rest-api.md",
49
+ "SECURITY.md",
44
50
  "LICENSE",
45
51
  "plugin.json",
46
52
  "mcp.json",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "zero-slop",
4
- "version": "2.9.2",
4
+ "version": "2.10.1",
5
5
  "description": "Find and fix canned AI writing while preserving the facts, voice, and format.",
6
6
  "author": {
7
7
  "name": "Manav Mishra",