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 +39 -15
- package/SECURITY.md +142 -0
- package/SKILL.md +6 -1
- package/bin/lib/deslop.d.mts +34 -0
- package/bin/lib/deslop.mjs +258 -0
- package/bin/zero-slop.mjs +162 -6
- package/docs/cli.md +73 -0
- package/docs/rest-api.md +101 -0
- package/gemini-extension.json +1 -1
- package/package.json +8 -2
- package/plugin.json +1 -1
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
|
-
<
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
83
|
+
The score describes writing patterns, not authorship.
|
|
86
84
|
|
|
87
85
|
## Install
|
|
88
86
|
|
|
89
|
-
|
|
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
|
-
|
|
98
|
+
Ask your assistant to edit:
|
|
101
99
|
|
|
102
100
|
```text
|
|
103
101
|
/zero-slop (your writing)
|
|
104
102
|
```
|
|
105
103
|
|
|
106
|
-
Inspect
|
|
104
|
+
Inspect without editing:
|
|
107
105
|
|
|
108
106
|
```text
|
|
109
107
|
/zero-slop inspect (your writing)
|
|
110
108
|
```
|
|
111
109
|
|
|
112
|
-
Score
|
|
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
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 ??
|
|
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.
|
|
395
|
+
.then((code) => { process.exitCode = code; })
|
|
240
396
|
.catch((err) => {
|
|
241
397
|
console.error(err.message);
|
|
242
|
-
process.
|
|
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.
|
package/docs/rest-api.md
ADDED
|
@@ -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.
|
package/gemini-extension.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zero-slop",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "An
|
|
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.
|
|
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",
|