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.
Files changed (44) hide show
  1. proseview/__init__.py +7 -0
  2. proseview/__main__.py +7 -0
  3. proseview/cli.py +359 -0
  4. proseview/codex_app_server.py +375 -0
  5. proseview/config.py +248 -0
  6. proseview/discuss.py +2138 -0
  7. proseview/editor.py +66 -0
  8. proseview/generator.py +629 -0
  9. proseview/goals.py +133 -0
  10. proseview/highlights.py +227 -0
  11. proseview/history.py +374 -0
  12. proseview/lexical.py +298 -0
  13. proseview/related.py +96 -0
  14. proseview/repo.py +516 -0
  15. proseview/scenes.py +451 -0
  16. proseview/server.py +1918 -0
  17. proseview/templates/assets/app.css +1841 -0
  18. proseview/templates/assets/js/00-state.js +108 -0
  19. proseview/templates/assets/js/10-prose-indicators.js +169 -0
  20. proseview/templates/assets/js/15-search.js +665 -0
  21. proseview/templates/assets/js/20-theme-font-charts.js +298 -0
  22. proseview/templates/assets/js/30-router-modal.js +420 -0
  23. proseview/templates/assets/js/40-prose-editor.js +237 -0
  24. proseview/templates/assets/js/50-discard-confirm.js +162 -0
  25. proseview/templates/assets/js/60-selection.js +1113 -0
  26. proseview/templates/assets/js/65-ai-proposals.js +805 -0
  27. proseview/templates/assets/js/68-discuss.js +1236 -0
  28. proseview/templates/assets/js/70-terminal.js +1315 -0
  29. proseview/templates/assets/js/80-sidebar-init.js +171 -0
  30. proseview/templates/index.html.j2 +653 -0
  31. proseview/templates/vendor/README.md +21 -0
  32. proseview/templates/vendor/chart.js +14 -0
  33. proseview/templates/vendor/chartjs-plugin-annotation.js +7 -0
  34. proseview/templates/vendor/marked.js +6 -0
  35. proseview/templates/vendor/xterm-addon-fit.js +8 -0
  36. proseview/templates/vendor/xterm.css +218 -0
  37. proseview/templates/vendor/xterm.js +8 -0
  38. proseview/watch.py +163 -0
  39. proseview-0.1.0.dist-info/METADATA +376 -0
  40. proseview-0.1.0.dist-info/RECORD +44 -0
  41. proseview-0.1.0.dist-info/WHEEL +5 -0
  42. proseview-0.1.0.dist-info/entry_points.txt +2 -0
  43. proseview-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. proseview-0.1.0.dist-info/top_level.txt +1 -0
proseview/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Proseview: dashboard and AI harness host for Markdown-first novel repos.
2
+
3
+ See ``visualization/plan/visualization-app-plan.md`` for the product spec and
4
+ ``visualization/plan/implementation-plan.md`` for the milestone order.
5
+ """
6
+
7
+ __version__ = "0.1.0"
proseview/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m proseview`` to invoke the CLI."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
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())