zero-slop 2.12.15 → 2.12.18

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.15" src="https://img.shields.io/badge/version-2.12.15-72528F?color=C15732">
17
+ <img alt="Version 2.12.18" src="https://img.shields.io/badge/version-2.12.18-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.15 deslop draft.md --genre professional
179
+ npx --yes zero-slop@2.12.18 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/SECURITY.md CHANGED
@@ -17,6 +17,18 @@ The installed skill ships ten standard-library Python modules:
17
17
  | `scripts/safeio.py` | locks and atomic file replacement | only on behalf of the two writers above | none |
18
18
  | `scripts/version_check.py` | optional release check | none | one metadata-only GitHub API request |
19
19
 
20
+ The Anthropic plugin folder additionally carries three host execution adapters:
21
+ `mcp_stdio.py`, `mcp_local_tools.py`, and `mcp_workflow_tools.py`. They expose the
22
+ existing local checks through a declared stdio MCP server; they do not run shell
23
+ commands or accept caller-selected file paths. Learning writes still require
24
+ affirmative consent, with separate consent for activation. The optional version
25
+ tool uses the same metadata-only release check. These adapters and their routing
26
+ overlay are absent from the portable skill, npm package, and OpenAI ZIP. The
27
+ Anthropic folder retains the separate remote connector for explicitly requested
28
+ hosted editing; local checks do not automatically invoke it.
29
+ The declared Python entry point in a repository subfolder can be held for manual
30
+ directory review. A declaration or local test does not establish scan approval.
31
+
20
32
  These local Python checks do not transmit the draft. Editing inside an assistant
21
33
  follows that assistant's privacy settings. The version checker sends only a GET
22
34
  for the latest public release tag, times out after 2.5 seconds, fails open, and can be
package/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: zero-slop
3
3
  license: MIT
4
4
  metadata:
5
- version: "2.12.15"
5
+ version: "2.12.18"
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
  ---
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.15
6
+ npm install --global zero-slop@2.12.18
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.12.15",
3
+ "version": "2.12.18",
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.15",
3
+ "version": "2.12.18",
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"
@@ -56,6 +56,9 @@
56
56
  "!scripts/build_onepager_pdf.py",
57
57
  "!scripts/build_plugin.py",
58
58
  "!scripts/build_marketplace_package.py",
59
+ "!scripts/mcp_stdio.py",
60
+ "!scripts/mcp_local_tools.py",
61
+ "!scripts/mcp_workflow_tools.py",
59
62
  "!scripts/build_skill_zip.py",
60
63
  "!scripts/check_svg.py",
61
64
  "!scripts/check_distribution_manifests.py",
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.15",
4
+ "version": "2.12.18",
5
5
  "description": "Find and fix canned AI writing while preserving the facts, voice, and format.",
6
6
  "author": {
7
7
  "name": "Manav Mishra",
@@ -95,8 +95,13 @@ def freqs(texts):
95
95
 
96
96
 
97
97
  def excess_weights(human_dir, ai_dir):
98
- hc, hn = freqs(read_corpus(human_dir))
99
- ac, an = freqs(read_corpus(ai_dir))
98
+ return excess_weights_from_texts(read_corpus(human_dir), read_corpus(ai_dir))
99
+
100
+
101
+ def excess_weights_from_texts(human_texts, ai_texts):
102
+ """Same frequency comparison for supplied texts; no paths or persistence."""
103
+ hc, hn = freqs(human_texts)
104
+ ac, an = freqs(ai_texts)
100
105
  out = {}
101
106
  for term, n_ai in ac.items():
102
107
  if n_ai < MIN_OBS or len(term) < 4:
package/scripts/learn.py CHANGED
@@ -656,8 +656,8 @@ def diff_spans(produced, shipped):
656
656
  return out
657
657
 
658
658
 
659
- def load_edit_feedback(path, produced, shipped, diffs):
660
- """Load optional per-edit reason labels bound to an exact before/after pair."""
659
+ def read_edit_feedback(path):
660
+ """Read a bounded feedback file once; semantic validation is separate."""
661
661
  source = Path(path)
662
662
  if not source.is_file():
663
663
  raise SystemExit(f"feedback is not a readable file: {source}")
@@ -667,6 +667,16 @@ def load_edit_feedback(path, produced, shipped, diffs):
667
667
  payload = json.loads(source.read_text())
668
668
  except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
669
669
  raise SystemExit(f"feedback must be readable UTF-8 JSON: {exc}") from exc
670
+ return payload
671
+
672
+
673
+ def load_edit_feedback(path, produced, shipped, diffs):
674
+ """Load optional per-edit reason labels bound to an exact before/after pair."""
675
+ return validate_edit_feedback(read_edit_feedback(path), produced, shipped, diffs)
676
+
677
+
678
+ def validate_edit_feedback(payload, produced, shipped, diffs):
679
+ """Validate source-bound edit labels without accepting a caller file path."""
670
680
  if (not isinstance(payload, dict)
671
681
  or set(payload) != {"schema", "source_sha256", "target_sha256", "edits"}
672
682
  or payload.get("schema") != 1
@@ -693,9 +703,28 @@ def load_edit_feedback(path, produced, shipped, diffs):
693
703
  return labels
694
704
 
695
705
 
696
- @state_locked(lambda *a, **k: OBS)
697
706
  def reflect(produced, shipped, doc_id=None, *, reason="unspecified", genre="general",
698
707
  feedback=None):
708
+ prod_text = required_text(produced, "produced draft")
709
+ ship_text = required_text(shipped, "shipped draft")
710
+ payload = None
711
+ if feedback:
712
+ # Keep the file interface and its existing bound for CLI callers.
713
+ payload = read_edit_feedback(feedback)
714
+ return reflect_text(prod_text, ship_text, reason=reason, genre=genre,
715
+ feedback=payload, labels=(Path(produced).name, Path(shipped).name))
716
+
717
+
718
+ @state_locked(lambda *a, **k: OBS)
719
+ def reflect_text(prod_text, ship_text, *, reason="unspecified", genre="general",
720
+ feedback=None, labels=("supplied draft", "supplied final text")):
721
+ """Record explicitly approved supplied edits using the existing safety gates.
722
+
723
+ No source files are written. Private evidence remains under ZERO_SLOP_HOME;
724
+ callers must obtain human opt-in before invoking this state-changing operation.
725
+ """
726
+ if not isinstance(prod_text, str) or not isinstance(ship_text, str):
727
+ raise ValueError("Reflection inputs must be text")
699
728
  reason = validate_label(reason, REASON_LABELS, "reason label")
700
729
  genre = validate_label(genre, GENRES, "genre")
701
730
  base = load(DATA / "patterns.json")
@@ -710,8 +739,6 @@ def reflect(produced, shipped, doc_id=None, *, reason="unspecified", genre="gene
710
739
  obs.setdefault("fix_observations", {})
711
740
 
712
741
  today = str(date.today())
713
- prod_text = required_text(produced, "produced draft")
714
- ship_text = required_text(shipped, "shipped draft")
715
742
  # A vote is one unique edit pair, regardless of filenames or caller-supplied
716
743
  # labels. Otherwise three copies of the same before/after text could cross
717
744
  # the recurrence threshold by changing only --doc-id. The optional label is
@@ -719,8 +746,8 @@ def reflect(produced, shipped, doc_id=None, *, reason="unspecified", genre="gene
719
746
  doc = hashlib.sha256(
720
747
  (prod_text + "\0" + ship_text).encode()).hexdigest()[:16]
721
748
  diffs = diff_spans(prod_text, ship_text)
722
- edit_labels = (load_edit_feedback(feedback, prod_text, ship_text, diffs)
723
- if feedback else {})
749
+ edit_labels = (validate_edit_feedback(feedback, prod_text, ship_text, diffs)
750
+ if feedback is not None else {})
724
751
  caught_words = {word for d in diffs if already_caught(d["span"], pats, lex)
725
752
  for word in norm(d["span"]).split()}
726
753
 
@@ -818,7 +845,7 @@ def reflect(produced, shipped, doc_id=None, *, reason="unspecified", genre="gene
818
845
 
819
846
  write_json(OBS, obs, private=True)
820
847
 
821
- print(f"reflect: {Path(produced).name} → {Path(shipped).name}\n")
848
+ print(f"reflect: {labels[0]} → {labels[1]}\n")
822
849
  print(f" {agreed} edit(s) Zero Slop had already flagged — the writer agreed")
823
850
  print(f" {skipped} content-specific cut(s) ignored (figures, proper nouns)")
824
851
  print(f" {recorded} new phrase(s) saved for review")
@@ -1242,11 +1269,8 @@ def merge(path, apply_, cat, weight):
1242
1269
  return 0
1243
1270
 
1244
1271
 
1245
- @state_locked(lambda *a, **k: LOCAL)
1246
1272
  def confirm(target):
1247
1273
  """Refresh evidence. Patterns that keep firing stay; the rest decay out."""
1248
- p = LOCAL
1249
- d = load_learned(p, "local")
1250
1274
  t = Path(target)
1251
1275
  if not t.exists():
1252
1276
  raise SystemExit(f"confirmation path does not exist: {t}")
@@ -1260,6 +1284,15 @@ def confirm(target):
1260
1284
  raise SystemExit(f"cannot read confirmation corpus at {t}: {exc}") from exc
1261
1285
  if not text.strip():
1262
1286
  raise SystemExit(f"confirmation corpus contains no text: {t}")
1287
+ return confirm_text(text, sample_count=len(files))
1288
+
1289
+
1290
+ @state_locked(lambda *a, **k: LOCAL)
1291
+ def confirm_text(text, *, sample_count=1):
1292
+ """Refresh private rules against supplied text, without caller paths."""
1293
+ if not isinstance(text, str) or not text.strip():
1294
+ raise ValueError("Confirmation input must contain text")
1295
+ d = load_learned(LOCAL, "local")
1263
1296
  today, n = str(date.today()), 0
1264
1297
  for pat in d.get("patterns", []):
1265
1298
  try:
@@ -1270,9 +1303,9 @@ def confirm(target):
1270
1303
  n += 1
1271
1304
  except re.error:
1272
1305
  continue
1273
- write_json(p, d, private=True)
1306
+ write_json(LOCAL, d, private=True)
1274
1307
  print(f"confirmed {n}/{len(d.get('patterns', []))} learned pattern(s) "
1275
- f"against {len(files)} file(s)")
1308
+ f"against {sample_count} sample(s)")
1276
1309
  return 0
1277
1310
 
1278
1311
 
@@ -1427,10 +1460,7 @@ def build_voice(name, sample_path):
1427
1460
  complete writing style. The resulting profile changes scoring only when
1428
1461
  the caller explicitly selects it with ``--voice NAME``.
1429
1462
  """
1430
- import slopscore
1431
1463
  name = safe_voice_name(name)
1432
- base = slopscore.load_patterns()
1433
- terms = list(base.get("lexicon", {})) + list(base.get("riders", {}))
1434
1464
  src = Path(sample_path)
1435
1465
  if not src.exists():
1436
1466
  raise SystemExit(f"voice sample path does not exist: {src}")
@@ -1444,6 +1474,18 @@ def build_voice(name, sample_path):
1444
1474
  raise SystemExit(f"cannot read voice sample at {src}: {exc}") from exc
1445
1475
  if not blob.strip():
1446
1476
  raise SystemExit(f"voice sample contains no text: {src}")
1477
+ return build_voice_text(name, blob)
1478
+
1479
+
1480
+ def build_voice_text(name, text):
1481
+ """Same exact-term profile from supplied text, never an arbitrary source path."""
1482
+ import slopscore
1483
+ name = safe_voice_name(name)
1484
+ if not isinstance(text, str) or not text.strip():
1485
+ raise ValueError("Voice sample must contain text")
1486
+ base = slopscore.load_patterns()
1487
+ terms = list(base.get("lexicon", {})) + list(base.get("riders", {}))
1488
+ blob = text.lower()
1447
1489
  keep = sorted({term for term in terms
1448
1490
  if re.search(r"\b" + re.escape(term.lower()) + r"\b", blob)})
1449
1491
  prof = {"_comment": f"Named scoring profile for {name}. Exact matches for "
@@ -1490,6 +1490,11 @@ def load_adjudication(path, original):
1490
1490
  payload = json.loads(source.read_text(encoding="utf-8"))
1491
1491
  except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
1492
1492
  raise ValueError(f"cannot read adjudication file: {exc}") from exc
1493
+ return validate_adjudication(payload, original)
1494
+
1495
+
1496
+ def validate_adjudication(payload, original):
1497
+ """Validate the same source-bound ruling supplied as JSON, without file I/O."""
1493
1498
  if not isinstance(payload, dict):
1494
1499
  raise ValueError("adjudication file must be a JSON object")
1495
1500
  expected = {"schema", "original_sha256", "allow_dropped_figures"}