sprag-cli 3.40.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/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Claude Code statusline (Windows PowerShell version)
|
|
2
|
+
# Mirrors the POSIX sh script — prints "user@host:cwd" then appends
|
|
3
|
+
# claude-token-saver output as a second segment.
|
|
4
|
+
#
|
|
5
|
+
# Install:
|
|
6
|
+
# 1) npm install -g claude-token-saver
|
|
7
|
+
# 2) Save this file as: %USERPROFILE%\.claude\statusline-command.ps1
|
|
8
|
+
# 3) In %USERPROFILE%\.claude\settings.json add:
|
|
9
|
+
# {
|
|
10
|
+
# "statusLine": {
|
|
11
|
+
# "type": "command",
|
|
12
|
+
# "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File %USERPROFILE%\\.claude\\statusline-command.ps1",
|
|
13
|
+
# "refreshInterval": 1
|
|
14
|
+
# }
|
|
15
|
+
# }
|
|
16
|
+
#
|
|
17
|
+
# Requires Windows Terminal or PowerShell 7+ for ANSI color + emoji rendering.
|
|
18
|
+
# (Classic conhost cmd renders colors but may garble emoji.)
|
|
19
|
+
|
|
20
|
+
$stdin = [Console]::In.ReadToEnd()
|
|
21
|
+
|
|
22
|
+
# Extract cwd from the JSON payload without requiring jq.
|
|
23
|
+
$cwdMatch = [regex]::Match($stdin, '"cwd"\s*:\s*"([^"]*)"')
|
|
24
|
+
$cwd = if ($cwdMatch.Success) { $cwdMatch.Groups[1].Value } else { (Get-Location).Path }
|
|
25
|
+
|
|
26
|
+
# 1) user@host:cwd (ANSI: green user@host, blue cwd)
|
|
27
|
+
$esc = [char]27
|
|
28
|
+
Write-Host -NoNewline "$esc[01;32m$env:USERNAME@$env:COMPUTERNAME$esc[00m`:$esc[01;34m$cwd$esc[00m"
|
|
29
|
+
|
|
30
|
+
# 2) cache monitor (appended). Separator " | ". Falls back silently.
|
|
31
|
+
Write-Host -NoNewline " $esc[90m|$esc[00m "
|
|
32
|
+
|
|
33
|
+
$cacheMonitor = Get-Command claude-token-saver -ErrorAction SilentlyContinue
|
|
34
|
+
if ($cacheMonitor) {
|
|
35
|
+
try {
|
|
36
|
+
& claude-token-saver --statusline --icon 2>$null
|
|
37
|
+
} catch { }
|
|
38
|
+
} else {
|
|
39
|
+
# fallback: npx (first run downloads the package; subsequent runs are warm)
|
|
40
|
+
try {
|
|
41
|
+
& npx --yes claude-token-saver@latest --statusline --icon 2>$null
|
|
42
|
+
} catch { }
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Claude Code statusline (POSIX sh — works on macOS, Linux, and WSL)
|
|
3
|
+
# Prints "user@host:cwd" then appends claude-token-saver as a second segment.
|
|
4
|
+
#
|
|
5
|
+
# Install:
|
|
6
|
+
# 1) npm install -g claude-token-saver
|
|
7
|
+
# 2) Save this file as: ~/.claude/statusline-command.sh
|
|
8
|
+
# chmod +x ~/.claude/statusline-command.sh (optional)
|
|
9
|
+
# 3) In ~/.claude/settings.json:
|
|
10
|
+
# {
|
|
11
|
+
# "statusLine": {
|
|
12
|
+
# "type": "command",
|
|
13
|
+
# "command": "bash ~/.claude/statusline-command.sh",
|
|
14
|
+
# "refreshInterval": 1
|
|
15
|
+
# }
|
|
16
|
+
# }
|
|
17
|
+
#
|
|
18
|
+
# refreshInterval keeps the TTL countdown ticking while you're idle.
|
|
19
|
+
# Drop to 2 or 5 if you want lower local CPU.
|
|
20
|
+
|
|
21
|
+
input=$(cat)
|
|
22
|
+
|
|
23
|
+
# Extract cwd without jq dependency (jq may not be installed system-wide).
|
|
24
|
+
cwd=$(echo "$input" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
|
25
|
+
cwd=${cwd:-$(pwd)}
|
|
26
|
+
|
|
27
|
+
# 1) user@host:cwd
|
|
28
|
+
printf '\033[01;32m%s@%s\033[00m:\033[01;34m%s\033[00m' "$(whoami)" "$(hostname -s)" "$cwd"
|
|
29
|
+
|
|
30
|
+
# 2) cache monitor (appended). Separator " | ". Falls back silently.
|
|
31
|
+
printf ' \033[90m|\033[00m '
|
|
32
|
+
if command -v claude-token-saver >/dev/null 2>&1; then
|
|
33
|
+
claude-token-saver --statusline --icon 2>/dev/null || true
|
|
34
|
+
else
|
|
35
|
+
npx --yes claude-token-saver@latest --statusline --icon 2>/dev/null || true
|
|
36
|
+
fi
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sprag-cli",
|
|
3
|
+
"version": "3.40.0",
|
|
4
|
+
"description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sprag": "bin/cli.js",
|
|
8
|
+
"claude-token-saver": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test",
|
|
12
|
+
"docs:statusline": "node scripts/docs-statusline.mjs",
|
|
13
|
+
"postinstall": "node bin/cli.js install || true"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"src/",
|
|
18
|
+
"presets/",
|
|
19
|
+
"examples/",
|
|
20
|
+
"README.md",
|
|
21
|
+
"README.ko.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"sprag",
|
|
29
|
+
"ratchet",
|
|
30
|
+
"harness",
|
|
31
|
+
"ai-agent",
|
|
32
|
+
"claude",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"anthropic",
|
|
35
|
+
"model-routing",
|
|
36
|
+
"llm-router",
|
|
37
|
+
"delegation",
|
|
38
|
+
"subagent",
|
|
39
|
+
"haiku",
|
|
40
|
+
"cost-savings",
|
|
41
|
+
"token-usage",
|
|
42
|
+
"prompt-caching",
|
|
43
|
+
"cache",
|
|
44
|
+
"statusline",
|
|
45
|
+
"cli",
|
|
46
|
+
"1m-context",
|
|
47
|
+
"claude-code-statusline",
|
|
48
|
+
"claude-code-cost",
|
|
49
|
+
"model-fitting",
|
|
50
|
+
"agent-harness"
|
|
51
|
+
],
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/rootstudioyaml/sprag.git"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://sprag.io",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/rootstudioyaml/sprag/issues"
|
|
60
|
+
},
|
|
61
|
+
"author": "DeepPulse (https://www.youtube.com/@DeepPulseKR)"
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
English cohesion guidance — claude-token-saver's own text, distilled from the
|
|
3
|
+
same sources as the Korean supplement's cohesion section: the given-new
|
|
4
|
+
contract from text linguistics, and three studies on Korean learner writing
|
|
5
|
+
whose validated principles are language-neutral (surface connectives
|
|
6
|
+
correlate negatively or not at all with judged text quality; elaboration is
|
|
7
|
+
the only connection type with a positive correlation).
|
|
8
|
+
|
|
9
|
+
Injected at session start when `claude-token-saver cohesion on` is set and
|
|
10
|
+
the Korean guidance is off (the Korean supplement already carries these
|
|
11
|
+
rules, so injecting both would bill the same principles twice).
|
|
12
|
+
-->
|
|
13
|
+
|
|
14
|
+
Follow these rules whenever you write English prose the user will read — answers, documents, reports, comments, UI copy. They govern how sentences connect, which is where generated text most often reads as stilted even when every sentence is individually fine.
|
|
15
|
+
|
|
16
|
+
1. **Move from known to new.** Start each sentence with information the reader already has; put the new information at the end. Let the next sentence pick up that new information and unpack it. When a transition feels rough, fix this ordering first — do not reach for a connective. Research on text quality found that surface connectives (however, moreover, additionally) correlate negatively or not at all with judged quality; elaboration — the next sentence developing what the previous one introduced — is the only connection type that correlates positively.
|
|
17
|
+
|
|
18
|
+
2. **One clear referent per pronoun.** If "it", "this", or "they" could point at more than one thing in the previous sentence, repeat the noun instead. Introduce people and organizations with a role tag on first mention ("the maintainer, Alice Park") rather than dropping a bare name.
|
|
19
|
+
|
|
20
|
+
3. **Keep one subject per paragraph.** Changing the grammatical subject every sentence forces the reader to reorient each time. Stay with one subject unless the topic actually shifts.
|
|
21
|
+
|
|
22
|
+
4. **No leaps.** If a sentence presupposes a condition or a cause the text has not established, add the bridging sentence rather than trusting the reader to reconstruct it. The most common failure is a new entity appearing with a definite article ("the report", "the agent") before anything has introduced it.
|
|
23
|
+
|
|
24
|
+
5. **Merge choppy repetition.** Three short sentences circling the same subject read worse than one sentence with the minor facts folded into modifiers. Demote the less important sentence to a clause; keep the core claim as the main clause.
|
|
25
|
+
|
|
26
|
+
Do not overcorrect: settled domain terms, formal register, and verbatim quotations stay as they are.
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Convert one office/PDF document to Markdown and print a JSON result.
|
|
3
|
+
|
|
4
|
+
Invoked as a child process by src/doc2md.cjs. Everything it needs to say
|
|
5
|
+
travels in the JSON on stdout, so the Node side never has to interpret a
|
|
6
|
+
traceback:
|
|
7
|
+
|
|
8
|
+
{"ok": true, "markdown": "...", "note": null, "truncated": false,
|
|
9
|
+
"rows": 0, "pages": 0, "markup_bytes": 0}
|
|
10
|
+
{"ok": false, "reason": "no-text", "detail": "..."}
|
|
11
|
+
|
|
12
|
+
Exit status is 0 whenever the JSON was written, including for a refusal. A
|
|
13
|
+
non-zero exit means the interpreter itself failed and the caller falls back to
|
|
14
|
+
letting the original file be read as it always was.
|
|
15
|
+
|
|
16
|
+
Two things here are defenses rather than features:
|
|
17
|
+
|
|
18
|
+
* Zip bombs. pptx/xlsx/docx are zip containers, and a hostile attachment can
|
|
19
|
+
declare a small size and expand to fill the disk. The central directory is
|
|
20
|
+
checked first because it is cheap, and then every member is decompressed in
|
|
21
|
+
chunks against a hard ceiling, because the central directory is written by
|
|
22
|
+
whoever built the file and can simply lie.
|
|
23
|
+
* Row count. Conversion time tracks spreadsheet rows, not bytes: a 6MB PDF
|
|
24
|
+
converts in about a second while a 6MB 200,000-row workbook takes about a
|
|
25
|
+
minute. Past the row cap the sheet is converted head-first by hand and the
|
|
26
|
+
truncation is stated in the result, because a silently shortened table is
|
|
27
|
+
worse than no table.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import zipfile
|
|
34
|
+
|
|
35
|
+
MAX_UNCOMPRESSED = 500 * 1024 * 1024
|
|
36
|
+
MAX_RATIO = 200
|
|
37
|
+
ROW_CAP = int(os.environ.get("CTS_DOC2MD_ROW_CAP", "50000"))
|
|
38
|
+
ZIP_EXTS = {".pptx", ".xlsx", ".docx"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fail(reason, detail=""):
|
|
42
|
+
json.dump({"ok": False, "reason": reason, "detail": str(detail)[:500]}, sys.stdout)
|
|
43
|
+
sys.exit(0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# A password-protected OOXML file is not a zip at all: Office wraps the whole
|
|
47
|
+
# package in an OLE compound file whose streams hold the ciphertext. Opening it
|
|
48
|
+
# as a zip therefore reports "not a zip file", which reads as a broken download
|
|
49
|
+
# and sends the user looking for the wrong problem.
|
|
50
|
+
OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def encryption_problem(path, ext):
|
|
54
|
+
"""('encrypted', detail) when the file is password-protected, else None."""
|
|
55
|
+
try:
|
|
56
|
+
with open(path, "rb") as fh:
|
|
57
|
+
head = fh.read(8)
|
|
58
|
+
except OSError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# Legacy .xls is an OLE file by design, so the magic alone proves nothing
|
|
62
|
+
# there. For the modern formats it can only mean encryption.
|
|
63
|
+
if ext in ZIP_EXTS and head == OLE_MAGIC:
|
|
64
|
+
return ("encrypted", "password-protected Office file (OLE-wrapped)")
|
|
65
|
+
|
|
66
|
+
# Some producers keep the zip container and put the ciphertext inside it.
|
|
67
|
+
if ext in ZIP_EXTS:
|
|
68
|
+
try:
|
|
69
|
+
import zipfile
|
|
70
|
+
with zipfile.ZipFile(path) as z:
|
|
71
|
+
names = z.namelist()
|
|
72
|
+
if any(n.startswith("EncryptedPackage") for n in names):
|
|
73
|
+
return ("encrypted", "password-protected Office file")
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
if ext == ".pdf":
|
|
78
|
+
try:
|
|
79
|
+
from pdfminer.pdfparser import PDFParser
|
|
80
|
+
from pdfminer.pdfdocument import PDFDocument
|
|
81
|
+
with open(path, "rb") as fh:
|
|
82
|
+
doc = PDFDocument(PDFParser(fh))
|
|
83
|
+
# An empty owner password is the ordinary "printing restricted"
|
|
84
|
+
# case, which extracts fine. Only a document that refuses to open
|
|
85
|
+
# counts as encrypted here.
|
|
86
|
+
if doc.encryption is not None and not doc.is_extractable:
|
|
87
|
+
return ("encrypted", "password-protected PDF")
|
|
88
|
+
except Exception as e:
|
|
89
|
+
if "password" in str(type(e).__name__).lower() or "password" in str(e).lower():
|
|
90
|
+
return ("encrypted", "password-protected PDF")
|
|
91
|
+
return None
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Enterprise DRM (Fasoo, MarkAny, SoftCamp and the like) does not password a
|
|
96
|
+
# document — it wraps the whole file, and only processes the vendor's agent
|
|
97
|
+
# has whitelisted ever see plaintext. Python is not one of them, so the bytes
|
|
98
|
+
# on disk are ciphertext with a vendor header. Names are matched only to say
|
|
99
|
+
# *which* product to go to; the classification does not depend on them.
|
|
100
|
+
DRM_MARKERS = [b"FASOO", b"MarkAny", b"MAWDRM", b"SoftCamp", b"Sherpa", b"TrustDRM",
|
|
101
|
+
b"DocuGate", b"WISEDRM", b"UNIDOCS", b"SecureDoc"]
|
|
102
|
+
# PDF DRM announces itself as a security handler, and these names are public.
|
|
103
|
+
PDF_DRM_FILTERS = [b"FOPN_foweb", b"EBX_HANDLER", b"Adobe.APS", b"FOPN_fLock"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def drm_hint(head):
|
|
107
|
+
"""Vendor name found in the file header, or None."""
|
|
108
|
+
for marker in DRM_MARKERS:
|
|
109
|
+
if marker in head or marker.decode().encode("utf-16-le") in head:
|
|
110
|
+
return marker.decode()
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def wrapper_problem(path, ext):
|
|
115
|
+
"""('drm-protected', detail) when the container is not the format at all."""
|
|
116
|
+
try:
|
|
117
|
+
with open(path, "rb") as fh:
|
|
118
|
+
head = fh.read(8192)
|
|
119
|
+
except OSError:
|
|
120
|
+
return None
|
|
121
|
+
if not head:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if ext in ZIP_EXTS:
|
|
125
|
+
# A truncated download still starts with a zip local-file header; a
|
|
126
|
+
# wrapped file does not start with anything of the format at all.
|
|
127
|
+
# Telling those two apart is the difference between "re-download it"
|
|
128
|
+
# and "get it released from DRM".
|
|
129
|
+
if head[:2] == b"PK" or head[:8] == OLE_MAGIC:
|
|
130
|
+
return None
|
|
131
|
+
vendor = drm_hint(head)
|
|
132
|
+
return ("drm-protected",
|
|
133
|
+
"DRM-wrapped file (%s)" % vendor if vendor else "the file is not an Office container at all")
|
|
134
|
+
|
|
135
|
+
if ext == ".pdf":
|
|
136
|
+
if not head.startswith(b"%PDF"):
|
|
137
|
+
vendor = drm_hint(head)
|
|
138
|
+
return ("drm-protected",
|
|
139
|
+
"DRM-wrapped file (%s)" % vendor if vendor else "the file is not a PDF at all")
|
|
140
|
+
try:
|
|
141
|
+
with open(path, "rb") as fh:
|
|
142
|
+
blob = fh.read(2_000_000)
|
|
143
|
+
except OSError:
|
|
144
|
+
return None
|
|
145
|
+
for f in PDF_DRM_FILTERS:
|
|
146
|
+
if f in blob:
|
|
147
|
+
return ("drm-protected", "PDF with a DRM security handler (%s)" % f.decode())
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_zip(path):
|
|
152
|
+
"""Classify an archive before opening it as a document.
|
|
153
|
+
|
|
154
|
+
Returns None when it is safe to convert, or a (reason, detail) pair. The
|
|
155
|
+
reason distinguishes a hostile file from a merely broken one: those want
|
|
156
|
+
opposite handling, and calling a truncated download a zip bomb would send
|
|
157
|
+
the user hunting for an attacker who is not there.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
with zipfile.ZipFile(path) as zf:
|
|
161
|
+
infos = zf.infolist()
|
|
162
|
+
declared = sum(i.file_size for i in infos)
|
|
163
|
+
packed = sum(i.compress_size for i in infos) or 1
|
|
164
|
+
if declared > MAX_UNCOMPRESSED or declared / packed > MAX_RATIO:
|
|
165
|
+
return ("unsafe-archive",
|
|
166
|
+
"declared size %d bytes at %.0fx compression" % (declared, declared / packed))
|
|
167
|
+
# The numbers above came from the archive itself, so verify them by
|
|
168
|
+
# actually decompressing, stopping the moment the running total
|
|
169
|
+
# passes the ceiling rather than once the disk is full. Understated
|
|
170
|
+
# sizes are caught here twice over: by this budget, and by the
|
|
171
|
+
# CRC-32 check zipfile performs while streaming, which fails as
|
|
172
|
+
# soon as a member's real contents disagree with its header.
|
|
173
|
+
budget = MAX_UNCOMPRESSED
|
|
174
|
+
for info in infos:
|
|
175
|
+
with zf.open(info) as member:
|
|
176
|
+
while True:
|
|
177
|
+
chunk = member.read(1 << 20)
|
|
178
|
+
if not chunk:
|
|
179
|
+
break
|
|
180
|
+
budget -= len(chunk)
|
|
181
|
+
if budget <= 0:
|
|
182
|
+
return ("unsafe-archive", "expands past %d bytes" % MAX_UNCOMPRESSED)
|
|
183
|
+
except zipfile.BadZipFile as e:
|
|
184
|
+
return ("bad-archive", str(e))
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Which entries inside a zip container hold the document's own text. The rest
|
|
189
|
+
# of the archive is media, themes and relationship tables — bytes a reader
|
|
190
|
+
# would never wade through even without a converter.
|
|
191
|
+
BODY_XML = {
|
|
192
|
+
".pptx": ("ppt/slides/", "ppt/notesSlides/"),
|
|
193
|
+
".docx": ("word/document.xml", "word/footnotes.xml", "word/endnotes.xml"),
|
|
194
|
+
".xlsx": ("xl/worksheets/", "xl/sharedStrings.xml"),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def markup_bytes(path, ext):
|
|
199
|
+
"""Uncompressed size of the body markup inside a zip document, or 0.
|
|
200
|
+
|
|
201
|
+
This prices the alternative to converting. A model cannot read the binary,
|
|
202
|
+
so the fallback a reader actually reaches for is unzipping the container
|
|
203
|
+
and wading through its XML — where tags and style attributes outweigh the
|
|
204
|
+
text several times over.
|
|
205
|
+
"""
|
|
206
|
+
prefixes = BODY_XML.get(ext)
|
|
207
|
+
if not prefixes:
|
|
208
|
+
return 0
|
|
209
|
+
try:
|
|
210
|
+
import zipfile
|
|
211
|
+
with zipfile.ZipFile(path) as z:
|
|
212
|
+
return sum(i.file_size for i in z.infolist()
|
|
213
|
+
if i.filename.endswith(".xml")
|
|
214
|
+
and any(i.filename.startswith(p) for p in prefixes))
|
|
215
|
+
except Exception:
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def pdf_pages(path):
|
|
220
|
+
"""Page count of a PDF, or 0 when it cannot be counted.
|
|
221
|
+
|
|
222
|
+
The count is what prices the alternative to converting: attaching a PDF
|
|
223
|
+
to a message bills every page as an image, while the conversion bills
|
|
224
|
+
only the extracted text. pdfminer ships with markitdown's pdf extra, so
|
|
225
|
+
this costs no extra dependency.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
from pdfminer.pdfpage import PDFPage
|
|
229
|
+
with open(path, "rb") as fh:
|
|
230
|
+
return sum(1 for _ in PDFPage.get_pages(fh))
|
|
231
|
+
except Exception:
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def sheet_rows(path):
|
|
236
|
+
"""Total rows across every sheet, or None when openpyxl cannot say."""
|
|
237
|
+
try:
|
|
238
|
+
import openpyxl
|
|
239
|
+
except ImportError:
|
|
240
|
+
return None
|
|
241
|
+
try:
|
|
242
|
+
wb = openpyxl.load_workbook(path, read_only=True)
|
|
243
|
+
try:
|
|
244
|
+
return sum(ws.max_row or 0 for ws in wb.worksheets)
|
|
245
|
+
finally:
|
|
246
|
+
wb.close()
|
|
247
|
+
except Exception:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def md_cell(v):
|
|
252
|
+
if v is None:
|
|
253
|
+
return ""
|
|
254
|
+
return str(v).replace("|", "\\|").replace("\n", " ")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def head_of_workbook(path, cap):
|
|
258
|
+
"""Markdown for the first `cap` rows, sheet by sheet.
|
|
259
|
+
|
|
260
|
+
Used only past the row cap. markitdown would produce nicer output, but it
|
|
261
|
+
reads the whole workbook first, which is the cost being avoided.
|
|
262
|
+
"""
|
|
263
|
+
import openpyxl
|
|
264
|
+
|
|
265
|
+
wb = openpyxl.load_workbook(path, read_only=True)
|
|
266
|
+
out = []
|
|
267
|
+
left = cap
|
|
268
|
+
try:
|
|
269
|
+
for ws in wb.worksheets:
|
|
270
|
+
if left <= 0:
|
|
271
|
+
break
|
|
272
|
+
out.append("## %s" % ws.title)
|
|
273
|
+
header_written = False
|
|
274
|
+
for row in ws.iter_rows(values_only=True):
|
|
275
|
+
if left <= 0:
|
|
276
|
+
break
|
|
277
|
+
cells = [md_cell(c) for c in row]
|
|
278
|
+
out.append("| " + " | ".join(cells) + " |")
|
|
279
|
+
if not header_written:
|
|
280
|
+
out.append("| " + " | ".join(["---"] * len(cells)) + " |")
|
|
281
|
+
header_written = True
|
|
282
|
+
left -= 1
|
|
283
|
+
out.append("")
|
|
284
|
+
finally:
|
|
285
|
+
wb.close()
|
|
286
|
+
return "\n".join(out)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main():
|
|
290
|
+
if len(sys.argv) < 2:
|
|
291
|
+
fail("usage", "convert.py <file>")
|
|
292
|
+
path = sys.argv[1]
|
|
293
|
+
if not os.path.isfile(path):
|
|
294
|
+
fail("missing", path)
|
|
295
|
+
|
|
296
|
+
ext = os.path.splitext(path)[1].lower()
|
|
297
|
+
|
|
298
|
+
# Checked before anything else opens the file: an encrypted document is a
|
|
299
|
+
# normal thing to receive, not a failure to report as corruption.
|
|
300
|
+
locked = encryption_problem(path, ext)
|
|
301
|
+
if locked:
|
|
302
|
+
fail(locked[0], locked[1])
|
|
303
|
+
|
|
304
|
+
# After the password check, because an OLE-wrapped Office file is an
|
|
305
|
+
# encrypted document rather than a DRM-wrapped one.
|
|
306
|
+
wrapped = wrapper_problem(path, ext)
|
|
307
|
+
if wrapped:
|
|
308
|
+
fail(wrapped[0], wrapped[1])
|
|
309
|
+
|
|
310
|
+
if ext in ZIP_EXTS:
|
|
311
|
+
problem = check_zip(path)
|
|
312
|
+
if problem:
|
|
313
|
+
fail(problem[0], problem[1])
|
|
314
|
+
|
|
315
|
+
note = None
|
|
316
|
+
truncated = False
|
|
317
|
+
rows = 0
|
|
318
|
+
pages = pdf_pages(path) if ext == ".pdf" else 0
|
|
319
|
+
markup = markup_bytes(path, ext)
|
|
320
|
+
|
|
321
|
+
if ext in (".xlsx", ".xls"):
|
|
322
|
+
counted = sheet_rows(path)
|
|
323
|
+
rows = counted or 0
|
|
324
|
+
if counted and counted > ROW_CAP:
|
|
325
|
+
try:
|
|
326
|
+
text = head_of_workbook(path, ROW_CAP)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
fail("convert-failed", e)
|
|
329
|
+
note = ("전체 %d행 가운데 앞 %d행만 변환했습니다. "
|
|
330
|
+
"전수 분석이 필요하면 원본을 직접 다루십시오." % (counted, ROW_CAP))
|
|
331
|
+
json.dump({"ok": True, "markdown": text, "note": note,
|
|
332
|
+
"truncated": True, "rows": counted, "pages": 0,
|
|
333
|
+
"markup_bytes": markup}, sys.stdout)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
from markitdown import MarkItDown
|
|
338
|
+
except ImportError as e:
|
|
339
|
+
fail("no-markitdown", e)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
result = MarkItDown().convert(path)
|
|
343
|
+
text = (result.text_content or "").strip()
|
|
344
|
+
except Exception as e:
|
|
345
|
+
# markitdown surfaces the password failure from whichever backend hit
|
|
346
|
+
# it, so the type name is the reliable part.
|
|
347
|
+
blob = (type(e).__name__ + " " + str(e)).lower()
|
|
348
|
+
if "password" in blob or "encrypted" in blob:
|
|
349
|
+
fail("encrypted", "the file is password-protected")
|
|
350
|
+
fail("convert-failed", e)
|
|
351
|
+
|
|
352
|
+
if not text:
|
|
353
|
+
# An empty file would read to the model as a document with nothing in
|
|
354
|
+
# it, which is a different and worse claim than "could not extract".
|
|
355
|
+
fail("no-text", "converter returned nothing")
|
|
356
|
+
|
|
357
|
+
json.dump({"ok": True, "markdown": text, "note": note,
|
|
358
|
+
"truncated": truncated, "rows": rows, "pages": pages,
|
|
359
|
+
"markup_bytes": markup}, sys.stdout)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__":
|
|
363
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 snflkd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF OR IN CONNECTION WITH
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Vendored from fluent-korean (https://github.com/snflkd/fluent-korean)
|
|
3
|
+
Copyright (c) 2026 snflkd — MIT License. Full text: ./LICENSE-fluent-korean
|
|
4
|
+
|
|
5
|
+
Only the output-style frontmatter was removed; the guidance below is
|
|
6
|
+
unmodified. claude-token-saver injects it at session start so the rules
|
|
7
|
+
apply in every project without installing the plugin or switching the
|
|
8
|
+
Claude Code output style.
|
|
9
|
+
-->
|
|
10
|
+
|
|
11
|
+
당신은 한국어를 활용해야 하는 상황이라면 본 문서에 제시된 지침들을 준수해야 합니다. 그럼으로써 의사 소통의 효율성을 높일 수 있습니다. 이 지침들은, 의미가 명확하며 비교적 가독성이 높고 안정적인 구조를 지닌 한국어 문장을 출력하는 방법을 자세히 설명합니다. 인용, 코드, 코드 주석에는 이 지침들을 적용하지 않습니다.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## 상황과 목표
|
|
15
|
+
|
|
16
|
+
- LLM은 한국어를 구사할 때 몇 가지 특징을 보이는데, 일부 특징은 결과물의 완성도를 낮추거나, 사용자가 소통에 더 많은 노력을 들이게 만듭니다. 이 문서에 작성된 사항들을 준수하면 이런 현상을 개선할 수 있습니다.
|
|
17
|
+
|
|
18
|
+
- 이 문서에서 제시하는 지침들을 요약하는 것은 일반적으로 권장되지 않습니다. 그렇게 한다면 조항마다 첨부된 예시를 확인할 수 없으므로 조항의 문구가 구체적으로 어떤 동작을 의도했는지 파악하기 어렵습니다. 또한 요약에 포함된 몇 가지 지침을 제외한 나머지 지침들은 잘 준수되지 않는 방향으로 서술 압력이 작동하게 될 수도 있습니다. 그리고 목적과 의도를 생략하고 제한 사항만 요약한다면 목적에 부합하지 않게 기계적으로 지침을 준수했는지 확인하게 될 수도 있습니다.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## 동작 범위
|
|
22
|
+
|
|
23
|
+
1. 본문의 지침들은 한국어를 활용하는 상황에서 그 한국어를 명확하게 출력하라는 지시입니다. 외국어 문장이나 어휘를 출력해야 하는 상황에서, 그것을 한국어로 번역하거나 대체하라는 지시가 아닙니다.
|
|
24
|
+
|
|
25
|
+
2. 변수명과 주석, 커밋 메시지, 로그 문자열처럼 코드에 속하는 텍스트는 프로젝트의 기존 관례를 준수해야 합니다. 이러한 텍스트는 지침을 적용하면 안 되기 때문에 이 조항에서 한 번 더 강조하고 있습니다.
|
|
26
|
+
|
|
27
|
+
3. 고유 명사와 기술 용어 등은, 통상적인 용례로 정착된 번역어 혹은 음차가 있다면 우선적으로 사용하고, 그렇지 않다면 원어를 유지함으로써, 한국어 사용자가 이해하기 편하고 의미를 잘 이해할 수 있도록 합니다.
|
|
28
|
+
|
|
29
|
+
4. 사용자가 어떤 어조나 어휘를 사용하든지, 사용자 메시지의 어조를 모방하지 않고, 본문에서 제시하는 지침들을 일관되게 유지합니다.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## 문장 단위
|
|
33
|
+
|
|
34
|
+
1. 읽는 이가 문장의 의미를 충분히 이해할 수 있어야 하므로, 의미가 있는 문장 성분을 생략하지 않습니다. [그러면 경고가 붙습니다.→ ('그러면 이미 작업중인 파일에도 경고 표지가 추가됩니다.'와 같이, 맥락과 정보를 충분히 제공하도록 수정) ] 특히 보조사 '의'를 필요 이상으로 사용한다면, 의미를 담고 있는 문장 성분을 생략하기 쉬우므로 유의해야 합니다. [사본의 문구는 작업의 상황을 → 사본에 기재된 문구는 작업이 진행되는 상황을]
|
|
35
|
+
|
|
36
|
+
2. (이 2번 조항은 헤더와 목록에는 강제로 적용되는 사항이 아닙니다.) 명사구나 부사구, 연결어미로 문장을 끝내지 말고, 서술어와 종결어미를 사용하여 완성된 형태의 문장으로 끝을 맺어야 합니다.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## 구 단위
|
|
40
|
+
|
|
41
|
+
1. 필수적인 경우가 아니라면 조사와 어미를 생략하지 말아야 합니다. 또한 부사, 보조사와 선어말어미, 보조 용언을 적극적으로 활용하면, 의미가 명확한 한국어 문장을 완성할 수 있습니다. [이 결정은 이후 중요 정책이 갈리는 자리. 컨텍스트 압축 전 신중 반영한다. → 이 결정은 이후 중요한 정책에 지속적으로 영향을 주기 때문에, 컨텍스트가 압축되기 전에 신중히 반영합니다. → 지금 답변해주신 결정 사항은 이후 중요한 정책에도 지속적으로 영향을 미치기 때문에, 컨텍스트가 압축되기 전에 미리 신중하게 반영해 놓겠습니다.]
|
|
42
|
+
|
|
43
|
+
2. 구체적인 의미를 담고 있는 한자어와 자연스러운 통사 구조를 결합하면, 풍부하고 명확한 의미를 전달할 수 있습니다. 따라서 맥락에 적합한 한자어를 적극적으로 활용하고, 그 한자어에 조사와 어미를 붙여서 어휘 사이의 관계를 확실하게 나타내야 합니다. [<쓴 비용을 구하는 토큰 카운트 함수에 문제가 생기면 (상황에 적합한 어휘가 사용되지 않아 의미가 불충분함) /지출 비용 추론 용도의 토큰 카운트 함수의 오류 상황에서 (조사와 어미가 없어 가독성이 낮고 의미 관계가 불분명함)> → 지출한 비용을 추론하는 토큰 카운트 함수에 오류가 발생하면 (이 지침의 목표 예시)]
|
|
44
|
+
|
|
45
|
+
3. 일반적인 어휘를 사용해야 하는 자리에 비유적 어휘를 사용하면 가독성이 낮고, 의미가 변질되기 쉽습니다. 따라서 꼭 필요한 경우가 아니라면 비유적 어휘로 일반적인 명사나 동사를 대체하지 않습니다. 다만 일상적인 문어에서 통용되고 지금 다루는 분야에서도 관용 표현으로 정착되어 있어서, 일반적인 어휘로 바꾸면 오히려 어색해지는 표현은 그대로 사용합니다. [<분석의 흐름 → 분석의 방향성>, <코드로 박는 자리 → 코드에 명시하는 상황 (혹은 코드에 명시하는 작업)>, <요청을 받습니다 -> 요청을 확인했습니다 (혹은 요청대로 수행하겠습니다)>]
|
|
46
|
+
|
|
47
|
+
4. 엠대시(—)는 앞뒤 문장의 관계를 지나치게 함축하기 때문에 자제하고, 문맥과 형식에 따라 콜론이나 접속사로 대체합니다.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
## 추가 사항
|
|
51
|
+
|
|
52
|
+
- 서브에이전트를 호출할 때, 한국어로 프롬프트를 작성했다면 실제로 서브에이전트 호출 도구를 사용하기 전에 이 본문의 지침들이 준수되어 있는지 점검합니다. 서브에이전트가 산출한 결과를 사용자에게 전달할 때에도 본문의 지침들이 그대로 적용됩니다.
|