zero-slop 2.7.6 → 2.7.7
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 +3 -3
- package/SKILL.md +1 -1
- package/package.json +1 -1
- package/scripts/slopscore.py +129 -4
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<img alt="tests" src="https://img.shields.io/badge/tests-passing-227B5B">
|
|
6
6
|
<img alt="dependencies" src="https://img.shields.io/badge/runtime%20dependencies-0-227B5B">
|
|
7
7
|
<img alt="privacy" src="https://img.shields.io/badge/learning-private-227B5B">
|
|
8
|
-
<img alt="version" src="https://img.shields.io/badge/version-2.7.
|
|
8
|
+
<img alt="version" src="https://img.shields.io/badge/version-2.7.7-72528F">
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
Take all the slop out of your AI writing. The #1 agentic anti-slop skill.
|
|
@@ -153,10 +153,10 @@ rates, and a method-hidden quality ranking.
|
|
|
153
153
|
This is a small LLM-reviewed regression study. It measures neither field accuracy nor a
|
|
154
154
|
universal ranking. Drafts, mappings, verdicts, hashes and limits:
|
|
155
155
|
[`bench/incumbent-blind-replay/`](bench/incumbent-blind-replay/). On the 38-item
|
|
156
|
-
editorial panel ([`bench/README.md`](bench/README.md)), v2.7.
|
|
156
|
+
editorial panel ([`bench/README.md`](bench/README.md)), v2.7.7 matched the prior 84.2% result
|
|
157
157
|
with every frozen document score unchanged, all 18 human controls clear and all 18 search
|
|
158
158
|
cases still caught: the release moves what the gate asks and leaves the meter
|
|
159
|
-
untouched. Median throughput was
|
|
159
|
+
untouched. Median throughput was 3.51% lower across 12 runs, which is local timing
|
|
160
160
|
noise and no kind of speed claim.
|
|
161
161
|
|
|
162
162
|
### Speed
|
package/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: zero-slop
|
|
3
3
|
license: MIT
|
|
4
4
|
metadata:
|
|
5
|
-
version: "2.7.
|
|
5
|
+
version: "2.7.7"
|
|
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
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zero-slop",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.7",
|
|
4
4
|
"description": "An Agent Skill that scores AI-sounding prose 0-100 locally and rewrites it without losing a fact. Runs inside Claude Code, Codex, Cursor, Warp, Zed and other SKILL.md agents. Offline, zero dependencies, MIT.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"zero-slop": "bin/zero-slop.mjs"
|
package/scripts/slopscore.py
CHANGED
|
@@ -45,6 +45,89 @@ class PatternData(dict):
|
|
|
45
45
|
"""JSON-compatible pattern mapping with an out-of-band compiled plan."""
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
# One-time note asking for a GitHub star.
|
|
49
|
+
#
|
|
50
|
+
# 448 machines cloned this in a fortnight and seventeen people had starred it,
|
|
51
|
+
# because nothing ever asked. The risk in fixing that is obvious: a tool whose
|
|
52
|
+
# whole job is deleting manipulative filler cannot itself nag, so every rule
|
|
53
|
+
# below is a restriction rather than a reach.
|
|
54
|
+
#
|
|
55
|
+
# - Once per machine, ever. A marker in the state directory, not a counter
|
|
56
|
+
# that resets.
|
|
57
|
+
# - Not until the third run, so it asks people who kept using it rather than
|
|
58
|
+
# people evaluating it once.
|
|
59
|
+
# - Never when the output is being read by a machine: --json, --batch,
|
|
60
|
+
# --gate, or any run whose stdout is not a terminal. CI logs stay clean.
|
|
61
|
+
# - No prompt, no keypress, no opening a browser, no network call. One line
|
|
62
|
+
# to stderr, so it cannot corrupt piped output even if the checks above
|
|
63
|
+
# were somehow wrong.
|
|
64
|
+
# - ZERO_SLOP_NO_NOTES=1 turns it off for good.
|
|
65
|
+
NOTES_FILE = HOME / "notes.json"
|
|
66
|
+
STAR_NOTE_AFTER_RUNS = 3
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_notes():
|
|
70
|
+
try:
|
|
71
|
+
with open(NOTES_FILE, encoding="utf-8") as fh:
|
|
72
|
+
data = json.load(fh)
|
|
73
|
+
return data if isinstance(data, dict) else {}
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _save_notes(state):
|
|
79
|
+
try:
|
|
80
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
tmp = NOTES_FILE.with_suffix(".json.tmp")
|
|
82
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
83
|
+
json.dump(state, fh)
|
|
84
|
+
os.replace(tmp, NOTES_FILE)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass # a read-only home must never break a score
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def star_note_is_due(argv=None, isatty=None, env=None):
|
|
90
|
+
"""Decide without writing anything, so the rule is testable in isolation."""
|
|
91
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
92
|
+
env = os.environ if env is None else env
|
|
93
|
+
if env.get("ZERO_SLOP_NO_NOTES"):
|
|
94
|
+
return False
|
|
95
|
+
if any(flag in argv for flag in ("--json", "--batch", "--gate")):
|
|
96
|
+
return False
|
|
97
|
+
if not (sys.stdout.isatty() if isatty is None else isatty):
|
|
98
|
+
return False
|
|
99
|
+
state = _load_notes()
|
|
100
|
+
if state.get("star_note_shown"):
|
|
101
|
+
return False
|
|
102
|
+
return int(state.get("human_runs", 0)) + 1 >= STAR_NOTE_AFTER_RUNS
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def record_human_run(argv=None, isatty=None, env=None):
|
|
106
|
+
"""Count this run and, if it is the one, return the note to print."""
|
|
107
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
108
|
+
env = os.environ if env is None else env
|
|
109
|
+
if env.get("ZERO_SLOP_NO_NOTES"):
|
|
110
|
+
return None
|
|
111
|
+
if any(flag in argv for flag in ("--json", "--batch", "--gate")):
|
|
112
|
+
return None
|
|
113
|
+
if not (sys.stdout.isatty() if isatty is None else isatty):
|
|
114
|
+
return None
|
|
115
|
+
state = _load_notes()
|
|
116
|
+
if state.get("star_note_shown"):
|
|
117
|
+
return None
|
|
118
|
+
state["human_runs"] = int(state.get("human_runs", 0)) + 1
|
|
119
|
+
due = state["human_runs"] >= STAR_NOTE_AFTER_RUNS
|
|
120
|
+
if due:
|
|
121
|
+
state["star_note_shown"] = True
|
|
122
|
+
_save_notes(state)
|
|
123
|
+
if not due:
|
|
124
|
+
return None
|
|
125
|
+
return ("\n If Zero Slop has been useful, a star helps people find it: "
|
|
126
|
+
"https://github.com/manavmishra/ZeroSlop\n"
|
|
127
|
+
" This is the only time you will see this. "
|
|
128
|
+
"ZERO_SLOP_NO_NOTES=1 silences all notes.")
|
|
129
|
+
|
|
130
|
+
|
|
48
131
|
def _voice_path(name):
|
|
49
132
|
"""Resolve a profile name without letting it become a filesystem path."""
|
|
50
133
|
if not VOICE_NAME.fullmatch(name or "") or name in (".", ".."):
|
|
@@ -159,6 +242,39 @@ def _apply_voice(base, name):
|
|
|
159
242
|
SENT_SPLIT = re.compile(r"(?<=[.!?])[\")”’]?\s+(?=[A-Z“\"(0-9])")
|
|
160
243
|
WORD = re.compile(r"[A-Za-z’']+")
|
|
161
244
|
|
|
245
|
+
|
|
246
|
+
# A quoted span longer than this is a passage, not a named tell, and stays in
|
|
247
|
+
# scope. Short enough to exempt "delve" or "it's not just X, it's Y"; short
|
|
248
|
+
# enough that quoting cannot be used to smuggle paragraphs past the meter.
|
|
249
|
+
QUOTE_SKIP_LIMIT = 200
|
|
250
|
+
|
|
251
|
+
_BLOCKQUOTE_SCAN_RX = re.compile(r"(?m)^[ \t]*>[ \t]?.*$")
|
|
252
|
+
_INLINE_QUOTE_RXS = (
|
|
253
|
+
re.compile(rf'"[^"\n]{{0,{QUOTE_SKIP_LIMIT}}}"'),
|
|
254
|
+
re.compile(rf"“[^”\n]{{0,{QUOTE_SKIP_LIMIT}}}”"),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def mask_quoted(text):
|
|
259
|
+
"""Blank quoted material for the pattern meter, keeping every offset.
|
|
260
|
+
|
|
261
|
+
Naming a cliche in order to discuss it is the opposite of committing it,
|
|
262
|
+
and step 0 of SKILL.md has always said to skip quotes. Only the phrase
|
|
263
|
+
meter and the lexicon honour that: rhythm, readability, word variety and
|
|
264
|
+
formatting still read the quotation, because a quote a writer chose to
|
|
265
|
+
include is part of how the finished page reads.
|
|
266
|
+
|
|
267
|
+
Spans are replaced character for character, so sentence offsets, word
|
|
268
|
+
counts and hit positions are identical to the unmasked text.
|
|
269
|
+
"""
|
|
270
|
+
def blank(match):
|
|
271
|
+
return re.sub(r"[^\n]", " ", match.group(0))
|
|
272
|
+
|
|
273
|
+
text = _BLOCKQUOTE_SCAN_RX.sub(blank, text)
|
|
274
|
+
for rx in _INLINE_QUOTE_RXS:
|
|
275
|
+
text = rx.sub(blank, text)
|
|
276
|
+
return text
|
|
277
|
+
|
|
162
278
|
# Normalise only detector-evasion characters, never ordinary non-Latin prose.
|
|
163
279
|
# A Cyrillic or Greek lookalike is mapped only when it appears in the same word
|
|
164
280
|
# as an ASCII letter (for example, dеlvе). This keeps Russian and Greek text
|
|
@@ -418,6 +534,10 @@ def score_text(text, data, formal=False):
|
|
|
418
534
|
if n_words >= 200 else None)
|
|
419
535
|
sent_spans = _sentence_spans(text)
|
|
420
536
|
sents = [text[a:b].replace("\n", " ") for a, b in sent_spans]
|
|
537
|
+
# Same string with quotations blanked out, used only by the phrase meter
|
|
538
|
+
# and the lexicon. Offsets match `text` exactly.
|
|
539
|
+
scan_text = mask_quoted(text)
|
|
540
|
+
scan_sents = [scan_text[a:b].replace("\n", " ") for a, b in sent_spans]
|
|
421
541
|
hits = []
|
|
422
542
|
pattern_spans = [] # (start, end, lower-rx, compiled-rx) for dedup below
|
|
423
543
|
|
|
@@ -449,10 +569,10 @@ def score_text(text, data, formal=False):
|
|
|
449
569
|
continue
|
|
450
570
|
if hints:
|
|
451
571
|
if lowercase_text is None:
|
|
452
|
-
lowercase_text =
|
|
572
|
+
lowercase_text = scan_text.lower()
|
|
453
573
|
if not any(hint in lowercase_text for hint in hints):
|
|
454
574
|
continue
|
|
455
|
-
for m in compiled.finditer(
|
|
575
|
+
for m in compiled.finditer(scan_text):
|
|
456
576
|
hits.append({
|
|
457
577
|
"cat": category, "name": name, "w": weight,
|
|
458
578
|
"quote": m.group(0)[:90].strip(),
|
|
@@ -484,7 +604,7 @@ def score_text(text, data, formal=False):
|
|
|
484
604
|
and (term in rx_lower or compiled.search(matched))
|
|
485
605
|
for ps, pe, rx_lower, compiled in pattern_spans)
|
|
486
606
|
|
|
487
|
-
candidates = [candidate for candidate in _term_candidates(
|
|
607
|
+
candidates = [candidate for candidate in _term_candidates(scan_text, data["lexicon"])
|
|
488
608
|
if not _pattern_owns(candidate[:2], candidate[2], candidate[4])]
|
|
489
609
|
last_end = 0
|
|
490
610
|
for s, e, term, w, quote in candidates:
|
|
@@ -494,7 +614,7 @@ def score_text(text, data, formal=False):
|
|
|
494
614
|
hits.append({"cat": "lexicon", "name": term, "w": w, "quote": quote})
|
|
495
615
|
riders, triggers = data.get("riders", {}), data.get("rider_triggers", [])
|
|
496
616
|
if riders:
|
|
497
|
-
for (a, _), sent in zip(sent_spans,
|
|
617
|
+
for (a, _), sent in zip(sent_spans, scan_sents):
|
|
498
618
|
sl = sent.lower()
|
|
499
619
|
if not any(t in sl for t in triggers):
|
|
500
620
|
continue
|
|
@@ -1787,6 +1907,11 @@ def main():
|
|
|
1787
1907
|
f"layout; your AI assistant still reviews the ideas, voice, and facts.")
|
|
1788
1908
|
sys.exit(0 if ok else 1)
|
|
1789
1909
|
|
|
1910
|
+
# Last line of a human run, and only ever once. See record_human_run.
|
|
1911
|
+
note = record_human_run()
|
|
1912
|
+
if note:
|
|
1913
|
+
print(note, file=sys.stderr)
|
|
1914
|
+
|
|
1790
1915
|
|
|
1791
1916
|
if __name__ == "__main__":
|
|
1792
1917
|
main()
|