zero-slop 2.12.22 → 2.12.24

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
@@ -14,7 +14,7 @@ Zero Slop is a free, open-source agent skill that finds and removes AI slop whil
14
14
 
15
15
  <p align="center">
16
16
  <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>
17
- <img alt="Version 2.12.22" src="https://img.shields.io/badge/version-2.12.22-72528F?color=C15732">
17
+ <img alt="Version 2.12.24" src="https://img.shields.io/badge/version-2.12.24-72528F?color=C15732">
18
18
  <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>
19
19
  <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>
20
20
  <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>
@@ -176,7 +176,7 @@ python3 scripts/slopscore.py --batch drafts/ --gate 25
176
176
  The CLI sends a file to the hosted editor without changing the file on disk:
177
177
 
178
178
  ```sh
179
- npx --yes zero-slop@2.12.22 deslop draft.md --genre professional
179
+ npx --yes zero-slop@2.12.24 deslop draft.md --genre professional
180
180
  ```
181
181
 
182
182
  Use `-` for stdin and `--json` for structured output. `--require-approved` prints the result but exits nonzero when review is needed. Requires Node.js 22+; offline `score` also needs Python 3. [CLI options and privacy](docs/cli.md).
package/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: zero-slop
3
3
  license: MIT
4
4
  metadata:
5
- version: "2.12.22"
5
+ version: "2.12.24"
6
6
  author: manavmishra
7
7
  description: Edit drafts into natural prose, inspect AI-sounding patterns, or review how a specified audience might respond passage by passage. Zero Slop runs inside the user's existing AI assistant with local tools that protect source details. Use for humanizing or de-slopping writing, polishing outward-facing prose, social drafts, final editorial checks, or an explicit simulated reader review. Preserve facts, voice and format; reader simulations are hypotheses, not human feedback.
8
8
  ---
@@ -74,7 +74,7 @@ export function validateResult(result) {
74
74
  || !validReport(result.before) || !validReport(result.after) || !number(result.scoreChange, -100, 100)
75
75
  || typeof result.factsPreserved !== "boolean" || typeof result.passedFinalChecks !== "boolean"
76
76
  || ![result.independentModelChecks, result.modelRequests, result.rolesCompleted, result.finishingRounds, result.durationMs].every(integer)
77
- || result.modelRequests > 1 || !string(result.scorerVersion) || !string(result.note)) {
77
+ || result.modelRequests > 2 || !string(result.scorerVersion) || !string(result.note)) {
78
78
  throw new DeslopError("invalid_response", "The MCP service returned an invalid structured result.");
79
79
  }
80
80
  // Return the original object, including additive fields; never rewrite its status.
package/docs/cli.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Use `deslop` for hosted editing with MCP parity. Use `score` for offline checks.
4
4
 
5
5
  ```sh
6
- npm install --global zero-slop@2.12.22
6
+ npm install --global zero-slop@2.12.24
7
7
  zero-slop deslop draft.md --genre professional
8
8
  zero-slop deslop - --genre email < draft.txt
9
9
  zero-slop deslop draft.md --json --require-approved
package/docs/rest-api.md CHANGED
@@ -102,7 +102,7 @@ The successful response contains these 14 fields:
102
102
  | `factsPreserved` | Whether tracked source details survived the checks |
103
103
  | `passedFinalChecks` | Whether the rewritten text passed the final checks |
104
104
  | `independentModelChecks` | Count of independent model checks; currently zero |
105
- | `modelRequests` | Editing-model requests made, zero or one |
105
+ | `modelRequests` | Provider attempts started, from zero to two; failed attempts count |
106
106
  | `rolesCompleted` | Completed pipeline responsibilities |
107
107
  | `finishingRounds` | Additional local finishing rounds |
108
108
  | `scorerVersion` | Deployed Zero Slop release |
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.12.22",
3
+ "version": "2.12.24",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.12.22",
3
+ "version": "2.12.24",
4
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"
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.12.22",
4
+ "version": "2.12.24",
5
5
  "description": "Find and fix canned AI writing while preserving the facts, voice, and format.",
6
6
  "author": {
7
7
  "name": "Manav Mishra",
package/scripts/rescue.py CHANGED
@@ -18,12 +18,89 @@ import sys
18
18
 
19
19
  MAX_INPUT_BYTES = 4 * 1024 * 1024
20
20
  PROTECTED = re.compile(
21
- r"```[\s\S]*?```|`[^`\n]+`|\[[^\]\n]+\]\([^)]+\)|https?://[^\s<]+|"
22
- r"“[^”\n]*”|‘[^’\n]*’|\"[^\"\n]*\""
21
+ r"```[\s\S]*?```|^[ \t]*>[^\n]*(?:\n[ \t]*>[^\n]*)*|"
22
+ r"`[^`\n]+`|\[[^\]\n]+\]\([^)]+\)|https?://[^\s<]+",
23
+ re.MULTILINE,
23
24
  )
24
25
  TOKEN = re.compile(r"\ue000(\d+)\ue001")
25
26
 
26
27
 
28
+ def _protected_ranges(text: str) -> list[tuple[int, int]]:
29
+ """Scan quote delimiters once; an unmatched opener never rescans a suffix."""
30
+ base = []
31
+ for match in PROTECTED.finditer(text):
32
+ end = match.end()
33
+ if match.group(0).startswith(("http://", "https://")):
34
+ # A closing prose quote may be adjacent to a URL. Leave its
35
+ # delimiter to the quote scanner, preserving all original bytes.
36
+ boundary = re.search(r"[\"'“”‘’][.!?,;:)]*$", match.group(0))
37
+ if boundary:
38
+ end = match.start() + boundary.start()
39
+ base.append((match.start(), end))
40
+ quotes: list[list[tuple[int, int]]] = [[], [], [], []]
41
+ opened: list[int | None] = [None, None, None, None]
42
+ base_index = 0
43
+
44
+ def word(char: str) -> bool:
45
+ return bool(char) and char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
46
+
47
+ for index, char in enumerate(text):
48
+ while base_index < len(base) and index >= base[base_index][1]:
49
+ base_index += 1
50
+ in_base = base_index < len(base) and base[base_index][0] <= index
51
+ if in_base:
52
+ continue
53
+ previous = text[index - 1] if index else ""
54
+ following = text[index + 1] if index + 1 < len(text) else ""
55
+ kind: int | None = None
56
+ starts = False
57
+ closes = False
58
+ if char == "“":
59
+ kind, starts = 0, True
60
+ elif char == "”":
61
+ kind, closes = 0, True
62
+ elif char == "‘":
63
+ kind, starts = 1, True
64
+ elif char == "’":
65
+ kind, closes = 1, not (word(previous) and word(following))
66
+ elif char == '"':
67
+ kind, starts, closes = 2, True, True
68
+ elif char == "'":
69
+ kind, starts, closes = 3, not word(previous), not word(following)
70
+ if kind is None:
71
+ continue
72
+ start = opened[kind]
73
+ if start is not None and closes:
74
+ quotes[kind].append((start, index + 1))
75
+ opened[kind] = None
76
+ elif start is None and starts:
77
+ opened[kind] = index
78
+
79
+ # An incomplete quoted passage is still source material. Abstain from
80
+ # editing its remaining suffix rather than guessing where the quote ended.
81
+ for kind, start in enumerate(opened):
82
+ if start is not None:
83
+ quotes[kind].append((start, len(text)))
84
+
85
+ # Each list is already ordered. Merge the fixed five lists in linear time,
86
+ # including nested quotes and protected code/links inside an outer quote.
87
+ lists = [base, *quotes]
88
+ positions = [0] * len(lists)
89
+ merged: list[tuple[int, int]] = []
90
+ while True:
91
+ available = [kind for kind, spans in enumerate(lists) if positions[kind] < len(spans)]
92
+ if not available:
93
+ break
94
+ kind = min(available, key=lambda key: lists[key][positions[key]][0])
95
+ start, end = lists[kind][positions[kind]]
96
+ positions[kind] += 1
97
+ if merged and start <= merged[-1][1]:
98
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
99
+ else:
100
+ merged.append((start, end))
101
+ return merged
102
+
103
+
27
104
  def _without_wrapper(match: re.Match[str]) -> str:
28
105
  prefix, word = match.group(1), match.group(2)
29
106
  # Capitalize the exposed sentence, but leave names such as iOS and eBay
@@ -37,12 +114,15 @@ def rescue_text(text: str) -> str:
37
114
  """Return a bounded, source-preserving edit; return clean text unchanged."""
38
115
  original = str(text or "").strip()
39
116
  protected: list[str] = []
40
-
41
- def mask(match: re.Match[str]) -> str:
42
- protected.append(match.group(0))
43
- return f"\ue000{len(protected) - 1}\ue001"
44
-
45
- masked = PROTECTED.sub(mask, original)
117
+ parts: list[str] = []
118
+ cursor = 0
119
+ for start, end in _protected_ranges(original):
120
+ parts.append(original[cursor:start])
121
+ protected.append(original[start:end])
122
+ parts.append(f"\ue000{len(protected) - 1}\ue001")
123
+ cursor = end
124
+ parts.append(original[cursor:])
125
+ masked = "".join(parts)
46
126
  out = re.sub(r"[\u00a0\u202f]", " ", masked)
47
127
  # Drop only a complete introductory clause; "Today" would add event timing.
48
128
  # A single unpunctuated line break may only wrap the current sentence.
@@ -60,6 +140,13 @@ def rescue_text(text: str) -> str:
60
140
  r"(^|[.!?][ \t\r\n]+|\r?\n[ \t]*\r?\n[ \t]*)as we move forward,[ \t]+the team will\b",
61
141
  lambda m: m.group(1) + "The team will", out, flags=re.IGNORECASE,
62
142
  )
143
+ # Remove one hype modifier without substituting emotion or paraphrasing
144
+ # an unspecified benefit. Preserve the announcement and its exact subject.
145
+ out = re.sub(
146
+ r"(^|[.!?][ \t\r\n]+|\r?\n[ \t]*\r?\n[ \t]*)([Ww]e)(['’]re| are) "
147
+ r"thrilled to announce a game[-\u2011]changing update\b",
148
+ lambda m: m.group(1) + m.group(2) + m.group(3) + " thrilled to announce an update", out,
149
+ )
63
150
  rules: list[tuple[str, str | object]] = [
64
151
  (
65
152
  r"\bwe are thrilled to unveil ([^,\n]+),\s+a transformative release that "
@@ -1265,8 +1265,30 @@ def facts(text, _other=""):
1265
1265
  # a feeling verb, because the goal is catching an INVENTED inner state, and the
1266
1266
  # comparison below cancels any that were already in the source.
1267
1267
  INTERIOR_STATE_RX = re.compile(
1268
- r"\b(?:I|we)\s+(?:genuinely\s+)?(?:was|were|am|felt|feel|got)\s+"
1269
- r"(?:(?:very|really|extremely|quite|so|genuinely)\s+)?(?P<state>[A-Za-z]+)", re.I)
1268
+ r"\b(?P<subject>I|we)(?:(?P<contraction>['’](?:m|re))|\s+"
1269
+ r"(?:genuinely\s+)?(?P<verb>was|were|am|are|felt|feel|got)(?P<negative>n['’]t)?)\s+"
1270
+ r"(?P<modifiers>(?:(?:very|really|extremely|quite|so|genuinely|not|never|hardly|"
1271
+ r"barely|somewhat|slightly|partly|mostly|almost|nearly|probably|possibly|"
1272
+ r"perhaps|usually|sometimes|occasionally)\s+)*)"
1273
+ r"(?P<state>[A-Za-z]+)\b", re.I)
1274
+ # New copula forms are limited to feelings, rather than treating "we're
1275
+ # exporting" or "we are product managers" as invented interior experience.
1276
+ # The older felt/was/am branches retain their existing experiential coverage.
1277
+ INTERIOR_EMOTIONS = frozenset("""afraid alarmed angry anxious ashamed astonished
1278
+ bewildered bitter bored calm comfortable confident confused content delighted
1279
+ depressed disappointed discouraged distressed eager elated embarrassed excited
1280
+ exhausted fearful familiar frustrated furious glad grateful happy hopeful humbled
1281
+ hurt isolated lonely miserable nervous overwhelmed panicked pleased proud relieved
1282
+ sad safe scared shocked surprised terrified thrilled tired uncomfortable uncertain
1283
+ uneasy unhappy unsafe upset worried""".split())
1284
+ INTERIOR_COSMETIC_INTENSITY = frozenset("very really extremely quite so genuinely".split())
1285
+ INTERIOR_CONTEXT_RX = re.compile(
1286
+ r"\b(?:if|unless|provided|assuming|when|once|until|whether|wish|wished|"
1287
+ r"may|might|could|would|should|possibly|probably|perhaps|reportedly|"
1288
+ r"allegedly|apparently)\b", re.I)
1289
+ INTERIOR_ATTRIBUTION_RX = re.compile(
1290
+ r"\b(?:said|says|wrote|writes|claimed|claims|reported|reports|told|tells|"
1291
+ r"according\s+to)\b", re.I)
1270
1292
  INTERIOR_COGNITION_RX = re.compile(
1271
1293
  r"\b(?:I|we)\s+(?P<cognition>remember(?:ed)?|recall(?:ed)?|realise(?:d)?|"
1272
1294
  r"realize(?:d)?|knew|fear(?:ed)?|hope(?:d)?|worr(?:y|ied)|panic(?:ked)?|"
@@ -1285,10 +1307,92 @@ COGNITION_CANON = {
1285
1307
  }
1286
1308
 
1287
1309
 
1310
+ def _quoted_interior_starts(text, starts):
1311
+ """Mark quotation/blockquote context in one forward delimiter scan."""
1312
+ quoted, opened = set(), [False, False, False, False]
1313
+ line_start = 0
1314
+ blockquote = False
1315
+ def word(value):
1316
+ return bool(value) and value in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
1317
+ for index, char in enumerate(text):
1318
+ if index == line_start:
1319
+ cursor = index
1320
+ while cursor < len(text) and text[cursor] in " \t":
1321
+ cursor += 1
1322
+ blockquote = cursor < len(text) and text[cursor] == ">"
1323
+ if index in starts and (blockquote or any(opened)):
1324
+ quoted.add(index)
1325
+ previous = text[index - 1] if index else ""
1326
+ following = text[index + 1] if index + 1 < len(text) else ""
1327
+ if char == "“":
1328
+ opened[0] = True
1329
+ elif char == "”":
1330
+ opened[0] = False
1331
+ elif char == "‘":
1332
+ opened[1] = True
1333
+ elif char == "’" and not (word(previous) and word(following)):
1334
+ opened[1] = False
1335
+ elif char == '"':
1336
+ opened[2] = not opened[2]
1337
+ elif char == "'":
1338
+ if opened[3] and not word(following):
1339
+ opened[3] = False
1340
+ elif not opened[3] and not word(previous):
1341
+ opened[3] = True
1342
+ if char == "\n":
1343
+ line_start = index + 1
1344
+ return quoted
1345
+
1346
+
1347
+ def _interior_states(text):
1348
+ # Literal code is protected by the structure gate, not an author's feeling.
1349
+ prose = INLINE_CODE_RX.sub(lambda m: " " * len(m.group(0)), _mask_fenced(text))
1350
+ matches = list(INTERIOR_STATE_RX.finditer(prose))
1351
+ quoted = _quoted_interior_starts(prose, {match.start() for match in matches})
1352
+ out, match_index, start = set(), 0, 0
1353
+ # Context is scanned once per sentence, avoiding a suffix scan per state.
1354
+ ends = [m.start() for m in re.finditer(r"[.!?](?=\s|$)|\n[ \t]*\n", prose)] + [len(prose)]
1355
+ for end in ends:
1356
+ sentence = prose[start:end]
1357
+ context = INTERIOR_CONTEXT_RX.search(sentence)
1358
+ attribution = INTERIOR_ATTRIBUTION_RX.search(sentence)
1359
+ context_signature = None
1360
+ if context:
1361
+ # Pin the exact qualification without copying a long sentence into
1362
+ # every state key or exposing it as a new diagnostics field.
1363
+ qualification = " ".join(sentence[context.start():].lower().split())
1364
+ context_signature = hashlib.sha256(qualification.encode("utf-8")).hexdigest()
1365
+ while match_index < len(matches) and matches[match_index].start() < end:
1366
+ match = matches[match_index]
1367
+ match_index += 1
1368
+ subject, state = match.group("subject").lower(), match.group("state").lower()
1369
+ contraction = (match.group("contraction") or "").replace("’", "'").lower()
1370
+ if contraction and contraction != ("'m" if subject == "i" else "'re"):
1371
+ continue
1372
+ if (contraction or match.group("verb").lower() == "are") and state not in INTERIOR_EMOTIONS:
1373
+ continue
1374
+ modifiers = [word.lower() for word in match.group("modifiers").split()
1375
+ if word.lower() not in INTERIOR_COSMETIC_INTENSITY]
1376
+ if match.group("negative"):
1377
+ modifiers.insert(0, "not")
1378
+ parts = [subject, *modifiers, state]
1379
+ if match.start() in quoted:
1380
+ parts.insert(0, "quoted")
1381
+ if attribution and start + attribution.start() < match.start():
1382
+ parts.insert(0, "attributed")
1383
+ if context_signature:
1384
+ # A hypothetical/qualified feeling must not license a new
1385
+ # unqualified assertion. Keep its actual context conservatively.
1386
+ parts.append("context=" + context_signature)
1387
+ out.add(":".join(parts))
1388
+ start = end + 1
1389
+ return out
1390
+
1391
+
1288
1392
  def interior_claims(text):
1289
1393
  """Inner-state assertions, reduced to a comparable core so paraphrase of an
1290
1394
  existing one does not read as a new invention."""
1291
- out = {m.group("state").lower() for m in INTERIOR_STATE_RX.finditer(text)}
1395
+ out = _interior_states(text)
1292
1396
  for m in INTERIOR_COGNITION_RX.finditer(text):
1293
1397
  word = m.group("cognition").lower()
1294
1398
  out.add(COGNITION_CANON.get(word, word))