proseview 0.1.0__py3-none-any.whl
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.
- proseview/__init__.py +7 -0
- proseview/__main__.py +7 -0
- proseview/cli.py +359 -0
- proseview/codex_app_server.py +375 -0
- proseview/config.py +248 -0
- proseview/discuss.py +2138 -0
- proseview/editor.py +66 -0
- proseview/generator.py +629 -0
- proseview/goals.py +133 -0
- proseview/highlights.py +227 -0
- proseview/history.py +374 -0
- proseview/lexical.py +298 -0
- proseview/related.py +96 -0
- proseview/repo.py +516 -0
- proseview/scenes.py +451 -0
- proseview/server.py +1918 -0
- proseview/templates/assets/app.css +1841 -0
- proseview/templates/assets/js/00-state.js +108 -0
- proseview/templates/assets/js/10-prose-indicators.js +169 -0
- proseview/templates/assets/js/15-search.js +665 -0
- proseview/templates/assets/js/20-theme-font-charts.js +298 -0
- proseview/templates/assets/js/30-router-modal.js +420 -0
- proseview/templates/assets/js/40-prose-editor.js +237 -0
- proseview/templates/assets/js/50-discard-confirm.js +162 -0
- proseview/templates/assets/js/60-selection.js +1113 -0
- proseview/templates/assets/js/65-ai-proposals.js +805 -0
- proseview/templates/assets/js/68-discuss.js +1236 -0
- proseview/templates/assets/js/70-terminal.js +1315 -0
- proseview/templates/assets/js/80-sidebar-init.js +171 -0
- proseview/templates/index.html.j2 +653 -0
- proseview/templates/vendor/README.md +21 -0
- proseview/templates/vendor/chart.js +14 -0
- proseview/templates/vendor/chartjs-plugin-annotation.js +7 -0
- proseview/templates/vendor/marked.js +6 -0
- proseview/templates/vendor/xterm-addon-fit.js +8 -0
- proseview/templates/vendor/xterm.css +218 -0
- proseview/templates/vendor/xterm.js +8 -0
- proseview/watch.py +163 -0
- proseview-0.1.0.dist-info/METADATA +376 -0
- proseview-0.1.0.dist-info/RECORD +44 -0
- proseview-0.1.0.dist-info/WHEEL +5 -0
- proseview-0.1.0.dist-info/entry_points.txt +2 -0
- proseview-0.1.0.dist-info/licenses/LICENSE +21 -0
- proseview-0.1.0.dist-info/top_level.txt +1 -0
proseview/__init__.py
ADDED
proseview/__main__.py
ADDED
proseview/cli.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Proseview command-line entry point.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
|
|
5
|
+
``proseview serve``
|
|
6
|
+
Start the local HTTP server (default if no subcommand is given).
|
|
7
|
+
|
|
8
|
+
``proseview init``
|
|
9
|
+
Drop a starter ``.proseview.yaml`` next to a manuscript folder, so a
|
|
10
|
+
new repo gets working defaults without hand-editing config.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import textwrap
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from urllib.error import HTTPError, URLError
|
|
21
|
+
from urllib.request import Request, urlopen
|
|
22
|
+
|
|
23
|
+
from .server import DEFAULT_PORT, serve
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
STARTER_CONFIG = """\
|
|
27
|
+
# proseview configuration. Every key has a sensible default; this
|
|
28
|
+
# file just documents what you can tune. Delete keys you don't want
|
|
29
|
+
# to override and proseview will fall back to the built-in defaults.
|
|
30
|
+
|
|
31
|
+
# Where the manuscript lives.
|
|
32
|
+
manuscript_path: manuscript/
|
|
33
|
+
|
|
34
|
+
# Where character bios live (one .md per character).
|
|
35
|
+
characters_path: story-bible/characters
|
|
36
|
+
|
|
37
|
+
# Where reusable agent prompts live.
|
|
38
|
+
skills_path: skills
|
|
39
|
+
|
|
40
|
+
# Word-count goal for the finished book.
|
|
41
|
+
target_words: 80000
|
|
42
|
+
|
|
43
|
+
# Daily word goal (drives the "days to finish" estimate).
|
|
44
|
+
daily_target: 500
|
|
45
|
+
|
|
46
|
+
# Healthy band for local lexical variety (MATTR).
|
|
47
|
+
mattr_band: [0.74, 0.77]
|
|
48
|
+
|
|
49
|
+
# Healthy band for whole-scene lexical variety (MTLD).
|
|
50
|
+
mtld_band: [105, 130]
|
|
51
|
+
|
|
52
|
+
# Editor URL handler. One of: vscode, cursor, zed, positron, custom.
|
|
53
|
+
editor:
|
|
54
|
+
scheme: vscode
|
|
55
|
+
|
|
56
|
+
# Folders shown in the file tree alongside the manuscript.
|
|
57
|
+
repo_tab:
|
|
58
|
+
folders: [plans, continuity, outline, story-bible, docs, templates]
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _add_serve_args(parser: argparse.ArgumentParser) -> None:
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--root", type=Path, default=Path.cwd(),
|
|
65
|
+
help="Path to the novel repo (default: current directory).",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--port", type=int, default=DEFAULT_PORT,
|
|
69
|
+
help=f"Port to listen on (default: {DEFAULT_PORT}).",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--interval", type=float, default=2.0,
|
|
73
|
+
help="File-watch polling interval in seconds (default: 2.0).",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--no-open", dest="open_browser", action="store_false", default=True,
|
|
77
|
+
help="Do not auto-open the browser.",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
82
|
+
parser = argparse.ArgumentParser(
|
|
83
|
+
prog="proseview",
|
|
84
|
+
description=(
|
|
85
|
+
"A local dashboard and AI harness for Markdown-first novel "
|
|
86
|
+
"repositories."
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
90
|
+
|
|
91
|
+
serve_p = sub.add_parser(
|
|
92
|
+
"serve", help="Start the dashboard server (default).",
|
|
93
|
+
description="Start the local HTTP server.",
|
|
94
|
+
)
|
|
95
|
+
_add_serve_args(serve_p)
|
|
96
|
+
|
|
97
|
+
init_p = sub.add_parser(
|
|
98
|
+
"init", help="Write a starter .proseview.yaml in the target repo.",
|
|
99
|
+
description="Write a starter .proseview.yaml in the target repo.",
|
|
100
|
+
)
|
|
101
|
+
init_p.add_argument(
|
|
102
|
+
"--root", type=Path, default=Path.cwd(),
|
|
103
|
+
help="Path to the novel repo (default: current directory).",
|
|
104
|
+
)
|
|
105
|
+
init_p.add_argument(
|
|
106
|
+
"--force", action="store_true",
|
|
107
|
+
help="Overwrite an existing .proseview.yaml.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
propose_p = sub.add_parser(
|
|
111
|
+
"propose", help="Create an AI proposal in a running proseview server.",
|
|
112
|
+
description="Create an AI proposal in a running proseview server.",
|
|
113
|
+
)
|
|
114
|
+
propose_p.add_argument("--root", type=Path, default=Path.cwd())
|
|
115
|
+
propose_p.add_argument("--file", required=True, help="Repo-relative manuscript file.")
|
|
116
|
+
propose_p.add_argument("--quote", default="", help="Exact passage to highlight.")
|
|
117
|
+
propose_p.add_argument("--start", type=int, default=None, help="Optional start offset in scene text.")
|
|
118
|
+
propose_p.add_argument("--end", type=int, default=None, help="Optional end offset in scene text.")
|
|
119
|
+
propose_p.add_argument("--start-line", type=int, default=None, help="Optional raw Markdown start line.")
|
|
120
|
+
propose_p.add_argument("--start-col", type=int, default=None, help="Optional raw Markdown start column.")
|
|
121
|
+
propose_p.add_argument("--end-line", type=int, default=None, help="Optional raw Markdown end line.")
|
|
122
|
+
propose_p.add_argument("--end-col", type=int, default=None, help="Optional raw Markdown end column.")
|
|
123
|
+
propose_p.add_argument("--message", required=True, help="Issue summary for the proposal.")
|
|
124
|
+
propose_p.add_argument(
|
|
125
|
+
"--option", action="append", required=True,
|
|
126
|
+
help="Replacement option. Pass multiple times for multiple choices.",
|
|
127
|
+
)
|
|
128
|
+
propose_p.add_argument("--client-id", default=None, help="Optional Proseview browser client id.")
|
|
129
|
+
|
|
130
|
+
proposal_p = sub.add_parser(
|
|
131
|
+
"proposal", help="Focus, update, apply, or skip an existing AI proposal.",
|
|
132
|
+
description="Focus, update, apply, or skip an existing AI proposal.",
|
|
133
|
+
)
|
|
134
|
+
proposal_sub = proposal_p.add_subparsers(dest="proposal_cmd", required=True)
|
|
135
|
+
status_p = proposal_sub.add_parser("status")
|
|
136
|
+
status_p.add_argument("id", nargs="?")
|
|
137
|
+
status_p.add_argument("--root", type=Path, default=Path.cwd())
|
|
138
|
+
for name in ("focus", "skip"):
|
|
139
|
+
p = proposal_sub.add_parser(name)
|
|
140
|
+
p.add_argument("id")
|
|
141
|
+
p.add_argument("--root", type=Path, default=Path.cwd())
|
|
142
|
+
p.add_argument("--client-id", default=None)
|
|
143
|
+
apply_p = proposal_sub.add_parser("apply")
|
|
144
|
+
apply_p.add_argument("id")
|
|
145
|
+
apply_p.add_argument("--root", type=Path, default=Path.cwd())
|
|
146
|
+
apply_p.add_argument("--option", type=int, default=1)
|
|
147
|
+
apply_p.add_argument("--client-id", default=None)
|
|
148
|
+
update_p = proposal_sub.add_parser("update")
|
|
149
|
+
update_p.add_argument("id")
|
|
150
|
+
update_p.add_argument("--root", type=Path, default=Path.cwd())
|
|
151
|
+
update_p.add_argument("--quote", default=None)
|
|
152
|
+
update_p.add_argument("--start", type=int, default=None)
|
|
153
|
+
update_p.add_argument("--end", type=int, default=None)
|
|
154
|
+
update_p.add_argument("--start-line", type=int, default=None)
|
|
155
|
+
update_p.add_argument("--start-col", type=int, default=None)
|
|
156
|
+
update_p.add_argument("--end-line", type=int, default=None)
|
|
157
|
+
update_p.add_argument("--end-col", type=int, default=None)
|
|
158
|
+
update_p.add_argument("--message", default=None)
|
|
159
|
+
update_p.add_argument("--option", action="append")
|
|
160
|
+
update_p.add_argument("--client-id", default=None)
|
|
161
|
+
|
|
162
|
+
# Top-level flags so ``proseview --root X`` (no subcommand) keeps
|
|
163
|
+
# working as a synonym for ``proseview serve --root X``.
|
|
164
|
+
_add_serve_args(parser)
|
|
165
|
+
return parser
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
169
|
+
"""Parse argv. Returned namespace always has a ``cmd`` attribute
|
|
170
|
+
(defaulting to ``"serve"``) plus the flags for that subcommand.
|
|
171
|
+
"""
|
|
172
|
+
args = _build_parser().parse_args(argv)
|
|
173
|
+
if args.cmd is None:
|
|
174
|
+
args.cmd = "serve"
|
|
175
|
+
return args
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def init_repo(root: Path, *, force: bool = False) -> int:
|
|
179
|
+
target = root / ".proseview.yaml"
|
|
180
|
+
if target.exists() and not force:
|
|
181
|
+
sys.stderr.write(
|
|
182
|
+
f"refusing to overwrite {target}; pass --force to replace it.\n"
|
|
183
|
+
)
|
|
184
|
+
return 1
|
|
185
|
+
target.write_text(STARTER_CONFIG, encoding="utf-8")
|
|
186
|
+
sys.stdout.write(f"wrote {target}\n")
|
|
187
|
+
sys.stdout.write(textwrap.dedent("""\
|
|
188
|
+
Next steps:
|
|
189
|
+
1. Create a manuscript/ folder with chapter subfolders (ch01/, ch02/...)
|
|
190
|
+
and one .md per scene.
|
|
191
|
+
2. Edit .proseview.yaml if your repo uses different folder names.
|
|
192
|
+
3. Run ``proseview serve`` to open the dashboard.
|
|
193
|
+
"""))
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _find_runtime_file(root: Path) -> Path | None:
|
|
198
|
+
cur = root.resolve()
|
|
199
|
+
for candidate in [cur, *cur.parents]:
|
|
200
|
+
path = candidate / ".proseview" / "server.json"
|
|
201
|
+
if path.exists():
|
|
202
|
+
return path
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _server_runtime(root: Path) -> tuple[str, str]:
|
|
207
|
+
"""Return the running server's ``(url, session_token)``.
|
|
208
|
+
|
|
209
|
+
Reading the token from ``.proseview/server.json`` is how the CLI proves it
|
|
210
|
+
is a local process rather than a web page: a browser can reach the port,
|
|
211
|
+
but it cannot read a file on disk.
|
|
212
|
+
"""
|
|
213
|
+
runtime = _find_runtime_file(root)
|
|
214
|
+
if not runtime:
|
|
215
|
+
raise SystemExit("No running proseview server found. Start `proseview serve` first.")
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(runtime.read_text(encoding="utf-8"))
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
raise SystemExit(f"Could not read {runtime}: {exc}") from exc
|
|
220
|
+
url = str(data.get("url") or "").rstrip("/")
|
|
221
|
+
if not url:
|
|
222
|
+
raise SystemExit(f"{runtime} does not contain a server url")
|
|
223
|
+
return url, str(data.get("session_token") or "")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _server_url(root: Path) -> str:
|
|
227
|
+
return _server_runtime(root)[0]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _request_json(root: Path, method: str, path: str, payload: dict) -> dict:
|
|
231
|
+
url, token = _server_runtime(root)
|
|
232
|
+
body = json.dumps(payload).encode("utf-8") if method != "GET" else None
|
|
233
|
+
headers = {"X-Proseview-Session": token} if token else {}
|
|
234
|
+
if body is not None:
|
|
235
|
+
headers["Content-Type"] = "application/json"
|
|
236
|
+
req = Request(
|
|
237
|
+
url + path,
|
|
238
|
+
data=body,
|
|
239
|
+
method=method,
|
|
240
|
+
headers=headers,
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
with urlopen(req, timeout=10) as resp:
|
|
244
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
245
|
+
except HTTPError as exc:
|
|
246
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
247
|
+
raise SystemExit(f"Proseview server returned {exc.code}: {detail}") from exc
|
|
248
|
+
except URLError as exc:
|
|
249
|
+
raise SystemExit(f"Could not reach proseview server: {exc.reason}") from exc
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def create_proposal(args: argparse.Namespace) -> int:
|
|
253
|
+
payload = {
|
|
254
|
+
"file": args.file,
|
|
255
|
+
"quote": args.quote,
|
|
256
|
+
"message": args.message,
|
|
257
|
+
"options": args.option,
|
|
258
|
+
"client_id": args.client_id,
|
|
259
|
+
"created_by": "codex",
|
|
260
|
+
}
|
|
261
|
+
if args.start is not None or args.end is not None:
|
|
262
|
+
if args.start is None or args.end is None:
|
|
263
|
+
raise SystemExit("--start and --end must be provided together")
|
|
264
|
+
payload["range"] = {"start": args.start, "end": args.end}
|
|
265
|
+
line_col = [args.start_line, args.start_col, args.end_line, args.end_col]
|
|
266
|
+
if any(v is not None for v in line_col):
|
|
267
|
+
if any(v is None for v in line_col):
|
|
268
|
+
raise SystemExit("--start-line, --start-col, --end-line, and --end-col must be provided together")
|
|
269
|
+
payload["range"] = {
|
|
270
|
+
"start_line": args.start_line,
|
|
271
|
+
"start_col": args.start_col,
|
|
272
|
+
"end_line": args.end_line,
|
|
273
|
+
"end_col": args.end_col,
|
|
274
|
+
}
|
|
275
|
+
data = _request_json(args.root, "POST", "/ai/proposals", payload)
|
|
276
|
+
if not data.get("ok"):
|
|
277
|
+
sys.stderr.write(f"proposal failed: {data.get('error', 'unknown error')}\n")
|
|
278
|
+
return 1
|
|
279
|
+
prop = data["proposal"]
|
|
280
|
+
sys.stdout.write(f"proposal {prop['id']} created for {prop['file']}\n")
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def proposal_action(args: argparse.Namespace) -> int:
|
|
285
|
+
cmd = args.proposal_cmd
|
|
286
|
+
payload: dict[str, object] = {}
|
|
287
|
+
if cmd == "status":
|
|
288
|
+
path = f"/ai/proposals/{args.id}" if args.id else "/ai/proposals"
|
|
289
|
+
data = _request_json(args.root, "GET", path, {})
|
|
290
|
+
if not data.get("ok"):
|
|
291
|
+
sys.stderr.write(f"proposal status failed: {data.get('error', 'unknown error')}\n")
|
|
292
|
+
return 1
|
|
293
|
+
sys.stdout.write(json.dumps(data, indent=2) + "\n")
|
|
294
|
+
return 0
|
|
295
|
+
if getattr(args, "client_id", None):
|
|
296
|
+
payload["client_id"] = args.client_id
|
|
297
|
+
if cmd == "update":
|
|
298
|
+
if args.quote is not None:
|
|
299
|
+
payload["quote"] = args.quote
|
|
300
|
+
if args.start is not None or args.end is not None:
|
|
301
|
+
if args.start is None or args.end is None:
|
|
302
|
+
raise SystemExit("--start and --end must be provided together")
|
|
303
|
+
payload["range"] = {"start": args.start, "end": args.end}
|
|
304
|
+
line_col = [args.start_line, args.start_col, args.end_line, args.end_col]
|
|
305
|
+
if any(v is not None for v in line_col):
|
|
306
|
+
if any(v is None for v in line_col):
|
|
307
|
+
raise SystemExit("--start-line, --start-col, --end-line, and --end-col must be provided together")
|
|
308
|
+
payload["range"] = {
|
|
309
|
+
"start_line": args.start_line,
|
|
310
|
+
"start_col": args.start_col,
|
|
311
|
+
"end_line": args.end_line,
|
|
312
|
+
"end_col": args.end_col,
|
|
313
|
+
}
|
|
314
|
+
if args.message is not None:
|
|
315
|
+
payload["message"] = args.message
|
|
316
|
+
if args.option:
|
|
317
|
+
payload["options"] = args.option
|
|
318
|
+
data = _request_json(args.root, "PATCH", f"/ai/proposals/{args.id}", payload)
|
|
319
|
+
else:
|
|
320
|
+
if cmd == "apply":
|
|
321
|
+
payload["option_index"] = args.option
|
|
322
|
+
data = _request_json(args.root, "POST", f"/ai/proposals/{args.id}/{cmd}", payload)
|
|
323
|
+
if not data.get("ok"):
|
|
324
|
+
sys.stderr.write(f"proposal {cmd} failed: {data.get('error', 'unknown error')}\n")
|
|
325
|
+
return 1
|
|
326
|
+
prop = data["proposal"]
|
|
327
|
+
sys.stdout.write(f"proposal {prop['id']} {cmd} queued\n")
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def main(argv: list[str] | None = None) -> int:
|
|
332
|
+
if argv is None:
|
|
333
|
+
argv = sys.argv[1:]
|
|
334
|
+
args = parse_args(argv)
|
|
335
|
+
|
|
336
|
+
if args.cmd == "init":
|
|
337
|
+
return init_repo(args.root, force=args.force)
|
|
338
|
+
if args.cmd == "propose":
|
|
339
|
+
return create_proposal(args)
|
|
340
|
+
if args.cmd == "proposal":
|
|
341
|
+
return proposal_action(args)
|
|
342
|
+
|
|
343
|
+
# Default: serve.
|
|
344
|
+
if args.interval <= 0:
|
|
345
|
+
raise SystemExit("--interval must be greater than zero")
|
|
346
|
+
try:
|
|
347
|
+
serve(
|
|
348
|
+
args.root.resolve(),
|
|
349
|
+
port=args.port,
|
|
350
|
+
watch_interval=args.interval,
|
|
351
|
+
open_browser=args.open_browser,
|
|
352
|
+
)
|
|
353
|
+
except KeyboardInterrupt:
|
|
354
|
+
return 0
|
|
355
|
+
return 0
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
raise SystemExit(main())
|