zero-slop 2.9.2 → 2.10.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/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,7 +30,7 @@ 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.0" src="https://img.shields.io/badge/version-2.10.0-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>
@@ -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.0 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
 
package/SECURITY.md ADDED
@@ -0,0 +1,142 @@
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 the capacity limiter; adding a transport does not create
52
+ an independent allowance. The limiter operates per Cloudflare location and is not
53
+ a strict global quota. REST errors omit request content. Responses use `no-store`,
54
+ and there is no stored response replay or idempotency cache.
55
+
56
+ REST adds completed results, failures, and capacity rejections to the existing
57
+ aggregate counters. It does not increment MCP initialization or tool-call counts.
58
+ REST logs contain only event name, outcome, character count, and elapsed time.
59
+
60
+ The gateway writes aggregate product and reliability events to Cloudflare Analytics
61
+ Engine. Recorded fields are limited to JSON-RPC method, tool, normalized client family
62
+ and major version, MCP protocol version, coarse country and data-center code, genre,
63
+ character and word counts, before and after scores and flag counts, result status,
64
+ completed-check counts, duration, and capacity outcome. Drafts, rewrites, prompts,
65
+ detected phrases, IP addresses, raw user agents, cookies, email addresses, and stable
66
+ user or session identifiers are excluded. Initializations are reported as connections,
67
+ not unique people. Analytics Engine retains the dataset for three months.
68
+
69
+ Telemetry writes are non-blocking and wrapped so an analytics failure cannot fail an
70
+ MCP call. The daily report uses sampling-aware aggregate queries and degrades to a
71
+ clearly labeled missing section if the dataset cannot be read.
72
+
73
+ Thirty-two SQLite-backed Durable Object shards keep lifetime counters for aggregate
74
+ MCP events: initializations, tool calls, completed results, changed messages,
75
+ warnings, failures, and capacity rejects. Each write is an atomic transaction with
76
+ a one-hour idempotency record, so bounded retries cannot double-count an event. The
77
+ protected report aggregates every shard and the legacy `global` object, preserving
78
+ totals recorded before sharding. It stores no request content or stable identity.
79
+ A report-only bearer secret protects
80
+ the read endpoint; the editor signing secret is never reused. Because MCP provides
81
+ no stable installation identifier, the service does not claim that an initialization
82
+ count is a unique-install count.
83
+
84
+ ## Online-learning isolation
85
+
86
+ Reflection evidence, local detector rules, recurring rewrite preferences, logs, and
87
+ named scoring profiles live under
88
+ `$ZERO_SLOP_HOME` (default `~/.zero-slop`) with owner-only file permissions. They are
89
+ not committed and are not overwritten by skill updates. The scorer loads the reviewed
90
+ shared taxonomy first, then this private overlay on every run.
91
+
92
+ One edit cannot activate a pattern. Phrase evidence needs the same cut across three
93
+ content-distinct before/after pairs; single words need five. Candidate rules must also
94
+ be new and must not match or borrow four consecutive words from the certified human
95
+ corpus. Repeated kept-text evidence can lower a local weight. Reconfirmation keeps a
96
+ local rule current; stale local detector rules decay after 18 months. A rewrite
97
+ preference also needs the same replacement across three content-distinct pairs and is
98
+ retired after 18 months without confirmation.
99
+
100
+ These controls limit blast radius; they do not make feedback trustworthy in the
101
+ cryptographic sense. A determined local user controls their own overlay. Shared changes
102
+ still require an explicit export, review, re-gating, tests, version bump, and release.
103
+
104
+ ## File integrity and path safety
105
+
106
+ Learning uses process-safe lock directories plus same-directory atomic replacement, so
107
+ concurrent reflections cannot silently overwrite one another and a crash cannot leave
108
+ half-written JSON. Corrupt reflection state fails closed instead of being reset.
109
+ Malformed learned rules degrade to the last valid shared layer rather than crashing the
110
+ scorer.
111
+
112
+ Voice-profile names are restricted to a short filename-safe alphabet. Contribution
113
+ exports must remain inside the working directory, cannot target `data/`, and cannot
114
+ overwrite an existing file. Imported contributions are untrusted: Zero Slop discards
115
+ their regexes, rebuilds patterns locally from the reviewed spans, and reruns the safety
116
+ gate.
117
+
118
+ The npm installer stages a complete copy before replacement. `--force` refuses roots,
119
+ the home directory, the current project, symlinks, files, and nonempty directories that
120
+ do not contain a verifiable Zero Slop runtime. A failed copy leaves the installed skill
121
+ in place.
122
+
123
+ ## Known limits
124
+
125
+ - A sample-based `--voice` profile records an existing lexicon or context-gated
126
+ watchlist term after one exact word match. It must be selected explicitly and
127
+ does not learn cadence, syntax, humor, tone, arbitrary phrases, or the writer's
128
+ full style.
129
+ - The human safety corpus contains twelve prose samples. It is a regression floor, not
130
+ proof that a pattern is safe for every dialect, genre, or language.
131
+ - The 0–100 result is a transparent heuristic surface score, not a calibrated
132
+ probability that AI wrote the text.
133
+ - Scripted fidelity checks cover figures, names, quotations, links, asserted
134
+ feelings, and protected document structure. Claim meaning, qualifiers, and voice
135
+ still require the final semantic review described in `SKILL.md`.
136
+ - Feedback recurrence proves content diversity, not independent authorship. Local
137
+ isolation prevents that limitation from changing the shared detector automatically.
138
+
139
+ ## Reporting
140
+
141
+ Open a GitHub issue, or use the email address on the maintainer's GitHub profile for a
142
+ 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.0"
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,258 @@
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
+ const headers = () => ({
187
+ "content-type": "application/json", accept: "application/json, text/event-stream",
188
+ "cache-control": "no-store", "mcp-protocol-version": protocolVersion,
189
+ ...(session ? { "mcp-session-id": session } : {}),
190
+ });
191
+ // A dropped HTTP connection is not an MCP cancellation notification. Request it
192
+ // separately, without replaying the tool, but bound this best-effort cleanup.
193
+ const notifyCancellation = () => {
194
+ if (!toolPending) return;
195
+ const cleanup = new AbortController();
196
+ const cleanupTimer = setTimeout(() => cleanup.abort(), 250);
197
+ void fetch(MCP_ENDPOINT, {
198
+ method: "POST", redirect: "manual", signal: cleanup.signal, headers: headers(),
199
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 2, reason: "Client cancelled" } }),
200
+ }).then((response) => response.body?.cancel()).catch(() => {}).finally(() => clearTimeout(cleanupTimer));
201
+ };
202
+ controller.signal.addEventListener("abort", notifyCancellation, { once: true });
203
+ async function rpc(body, notification = false) {
204
+ controller.signal.throwIfAborted();
205
+ const response = await fetch(MCP_ENDPOINT, {
206
+ method: "POST", redirect: "manual", signal: controller.signal, headers: headers(), body: JSON.stringify(body),
207
+ });
208
+ controller.signal.throwIfAborted();
209
+ if (!response.ok) {
210
+ void response.body?.cancel().catch(() => {});
211
+ const retry = response.headers.get("retry-after");
212
+ const retryAfterSeconds = retry && /^\d{1,5}$/.test(retry) ? Number(retry) : undefined;
213
+ throw new DeslopError("http_error", `The MCP service returned HTTP ${response.status}. No retry was sent.`, {
214
+ httpStatus: response.status, ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
215
+ });
216
+ }
217
+ if (notification) {
218
+ void response.body?.cancel().catch(() => {});
219
+ return;
220
+ }
221
+ if (body.method === "initialize") {
222
+ session = response.headers.get("mcp-session-id");
223
+ if (session && !/^[\x21-\x7e]{1,512}$/.test(session)) {
224
+ void response.body?.cancel().catch(() => {});
225
+ throw new DeslopError("invalid_response", "The MCP service returned an invalid session identifier.");
226
+ }
227
+ }
228
+ return readReply(response, body.id, controller.signal);
229
+ }
230
+ try {
231
+ const initialized = await rpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: {
232
+ protocolVersion, capabilities: {}, clientInfo: { name: clientName, version: clientVersion },
233
+ } });
234
+ if (!PROTOCOLS.includes(initialized.protocolVersion)) {
235
+ throw new DeslopError("unsupported_protocol", "The MCP service negotiated an unsupported protocol version.");
236
+ }
237
+ protocolVersion = initialized.protocolVersion;
238
+ await rpc({ jsonrpc: "2.0", method: "notifications/initialized" }, true);
239
+ toolPending = true;
240
+ const reply = await rpc(toolBody);
241
+ toolPending = false;
242
+ if (reply.isError !== undefined && typeof reply.isError !== "boolean") {
243
+ throw new DeslopError("invalid_response", "The MCP service returned an invalid tool result.");
244
+ }
245
+ if (reply.isError === true) {
246
+ throw new DeslopError("tool_error", "Zero Slop could not return a safely checked result. The source was not changed locally.");
247
+ }
248
+ return validateResult(reply.structuredContent);
249
+ } catch (error) {
250
+ if (controller.signal.aborted) throw controller.signal.reason;
251
+ if (error instanceof DeslopError) throw error;
252
+ throw new DeslopError("network_error", "The hosted MCP request failed. Its outcome may be unknown; no retry was sent.");
253
+ } finally {
254
+ clearTimeout(timer);
255
+ options.signal?.removeEventListener("abort", cancel);
256
+ controller.signal.removeEventListener("abort", notifyCancellation);
257
+ }
258
+ }
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,73 @@
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.0
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
+ `deslop` reads exactly one file or standard input, sends the draft to
17
+ `https://mcp.zero-slop.ai/mcp`, and writes the returned text to standard output.
18
+ It never edits a source file in place. Warnings go to standard error. Capture
19
+ standard output only when you intend to save it.
20
+
21
+ ## Options and results
22
+
23
+ | Option | Purpose |
24
+ |---|---|
25
+ | `--genre` | `general`, `social`, `email`, `research`, or `professional` |
26
+ | `--audience` | Intended reader or destination, up to 200 Unicode code points |
27
+ | `--json` | Full MCP result, including all check fields and status |
28
+ | `--require-approved` | Exit nonzero if the result needs review |
29
+ | `--timeout` | Remote request deadline in seconds: 75 by default, up to 300 |
30
+
31
+ The draft limit is 20,000 Unicode code points after trimming. Directory uploads,
32
+ automatic chunking, and automatic retries are not supported. Only the draft and
33
+ supplied genre/audience are transmitted; filenames and private learning profiles
34
+ stay local. See the [remote-service privacy boundary](../SECURITY.md#remote-service-boundary).
35
+
36
+ Inspect the [six result statuses](rest-api.md#read-the-result). In particular,
37
+ `rewritten_with_warnings` is an edit to review, not an approved result.
38
+ `already_clear` returns the original without an editing-model request.
39
+
40
+ | Exit code | Meaning |
41
+ |---|---|
42
+ | `0` | A valid result was returned; inspect its status before using it |
43
+ | `1` | Transport or service failure |
44
+ | `2` | Invalid input or options |
45
+ | `3` | `--require-approved` rejected the result; the result is still printed |
46
+ | `124` | Remote request timed out |
47
+ | `130` / `143` | Interrupted / terminated |
48
+
49
+ A timeout or cancellation may not stop hosted processing. The CLI does not replay
50
+ the request. Review any returned status or error before choosing to send the draft again.
51
+
52
+ ## Offline scoring
53
+
54
+ ```sh
55
+ zero-slop score draft.md -- --json
56
+ zero-slop score - -- --json < draft.txt
57
+ zero-slop score docs/ -- --batch --json --gate 25
58
+ ```
59
+
60
+ The scorer does not transmit drafts. A numerical gate fails when the score exceeds
61
+ its threshold; equality passes. Its score measures writing patterns, not authorship
62
+ or factual truth. Hosted editorial approval includes additional checks and should
63
+ not be inferred from the number alone.
64
+
65
+ ## Install the portable skill
66
+
67
+ ```sh
68
+ zero-slop install
69
+ ```
70
+
71
+ The installed skill runs inside a compatible AI assistant. Its editing follows that
72
+ assistant's privacy settings. Installing the skill does not send your writing to
73
+ the hosted service.
@@ -0,0 +1,101 @@
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
+ ## Send a draft
12
+
13
+ ```sh
14
+ curl --fail-with-body --max-time 75 https://mcp.zero-slop.ai/v1/deslop \
15
+ -H 'Content-Type: application/json' \
16
+ --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"}'
17
+ ```
18
+
19
+ | Field | Accepted value |
20
+ |---|---|
21
+ | `text` | Required, nonempty string; up to 20,000 Unicode code points after trimming |
22
+ | `genre` | `general` (default), `social`, `email`, `research`, or `professional` |
23
+ | `audience` | Optional string, up to 200 Unicode code points after trimming |
24
+
25
+ Lengths count Unicode code points; a combined emoji or accented character can contain more than one.
26
+ Send UTF-8 JSON in the body, never a URL query. The body limit is 128 KiB, including
27
+ JSON escaping. Compressed bodies are not accepted. Unknown properties are ignored.
28
+
29
+ Call from your application server. The service does not enable cross-origin browser
30
+ requests. Access is free, without an API key, and shares MCP's capacity limiter.
31
+ Capacity is best effort; there is no reserved quota or uptime SLA.
32
+
33
+ ## Read the result
34
+
35
+ HTTP `200` means the pipeline returned a result. It does not mean the edit passed
36
+ every check.
37
+
38
+ | `status` | What to do |
39
+ |---|---|
40
+ | `rewritten` | Review the edit; the model edit passed the local writing and source checks |
41
+ | `already_clear` | Keep the original; no editing-model call was needed |
42
+ | `rewritten_with_warnings` | Read `note` and review the result before using it |
43
+ | `unchanged_no_better_version` | Keep the original; no better version was selected |
44
+ | `unchanged_verification_failed` | Keep the original; proposed edits failed source checks |
45
+ | `unchanged_service_unavailable` | Keep the original; the model did not yield a usable edit |
46
+
47
+ The response also contains `before` and `after` writing reports, `scoreChange`,
48
+ `factsPreserved`, `passedFinalChecks`, `independentModelChecks`, `modelRequests`,
49
+ `rolesCompleted`, `finishingRounds`, `scorerVersion`, `durationMs`, `text`, and `note`.
50
+ `scoreChange` is after minus before, so a negative value means the measured score fell.
51
+ The writing score is not a probability that AI wrote the draft.
52
+
53
+ For an automation that must stop on a warning, use this condition:
54
+
55
+ ```js
56
+ const approved = result.factsPreserved === true && (
57
+ (result.status === "already_clear" && result.modelRequests === 0 &&
58
+ result.scoreChange === 0 && result.before.score === result.after.score) ||
59
+ (result.status === "rewritten" && result.passedFinalChecks === true)
60
+ );
61
+ ```
62
+
63
+ `already_clear` has `passedFinalChecks: false` because editing-model checks were not
64
+ needed. Do not reject it solely on that boolean. Source checks protect tracked
65
+ details; they cannot certify factual truth or every change in meaning. Human review
66
+ remains necessary before publication.
67
+
68
+ ## Errors and retries
69
+
70
+ Errors use `application/problem+json` with `type`, `title`, `status`, `detail`,
71
+ `code`, and `requestId`. Their details do not echo drafts, model responses, or secrets.
72
+
73
+ | HTTP status | Meaning |
74
+ |---|---|
75
+ | `400` | Invalid JSON, UTF-8, declared length, or input fields |
76
+ | `405` | Wrong method; use `POST` |
77
+ | `408` | The request body did not arrive within 10 seconds |
78
+ | `413` | The JSON body exceeds 128 KiB |
79
+ | `415` | Unsupported media type or compression |
80
+ | `429` | Shared capacity reached; respect `Retry-After` |
81
+ | `503` | No safely scored result was available |
82
+
83
+ Allow 75 seconds on the client. The service makes at most one editing-model request;
84
+ scoring and source checks add their own bounded work. A client timeout or cancellation
85
+ does not guarantee upstream work stopped. Do not retry automatically after an uncertain
86
+ outcome. If a user chooses to retry a `429`, wait at least the `Retry-After` delay.
87
+
88
+ The API does not store drafts or results for idempotency. Repeated POSTs are separate
89
+ calls, may repeat work, and are not guaranteed to return identical text.
90
+
91
+ ## Privacy and versioning
92
+
93
+ The draft is sent to Zero Slop's hosted service and processed in memory. The upstream
94
+ editing service must confirm `stored: false`; drafts and rewrites are not cached or logged by this
95
+ service. Aggregate operational counts remain. See [SECURITY.md](../SECURITY.md) and
96
+ the [hosted-service terms](https://zero-slop.ai/terms/).
97
+
98
+ `/v1` identifies the HTTP contract. `scorerVersion` identifies the deployed Zero Slop
99
+ release. Clients should accept new optional response fields and treat any unrecognized
100
+ result status as requiring review. The OpenAPI document is served by the same Worker
101
+ 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.0",
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.0",
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.0",
5
5
  "description": "Find and fix canned AI writing while preserving the facts, voice, and format.",
6
6
  "author": {
7
7
  "name": "Manav Mishra",