netcodex-agent-exporter 0.3.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.
- netcodex/__init__.py +6 -0
- netcodex/cli.py +309 -0
- netcodex/incremental.py +106 -0
- netcodex/models.py +145 -0
- netcodex/renderers/__init__.py +20 -0
- netcodex/renderers/markdown.py +279 -0
- netcodex/renderers/quarkdown.py +352 -0
- netcodex/security/__init__.py +4 -0
- netcodex/security/redaction.py +57 -0
- netcodex/services.py +690 -0
- netcodex/sources/__init__.py +5 -0
- netcodex/sources/aider.py +169 -0
- netcodex/sources/antigravity.py +236 -0
- netcodex/sources/claude.py +581 -0
- netcodex/sources/claude_ai.py +194 -0
- netcodex/sources/cline.py +225 -0
- netcodex/sources/codex.py +602 -0
- netcodex/sources/common.py +261 -0
- netcodex/sources/continue_dev.py +144 -0
- netcodex/sources/copilot_chat.py +307 -0
- netcodex/sources/copilot_cli.py +152 -0
- netcodex/sources/cursor.py +174 -0
- netcodex/sources/encrypted.py +77 -0
- netcodex/sources/gemini_cli.py +184 -0
- netcodex/sources/paths.py +110 -0
- netcodex/sources/registry.py +155 -0
- netcodex/web/__init__.py +1 -0
- netcodex/web/api.py +371 -0
- netcodex/web/local_ui.py +190 -0
- netcodex/web/static/local.html +198 -0
- netcodex_agent_exporter-0.3.0.dist-info/METADATA +275 -0
- netcodex_agent_exporter-0.3.0.dist-info/RECORD +35 -0
- netcodex_agent_exporter-0.3.0.dist-info/WHEEL +4 -0
- netcodex_agent_exporter-0.3.0.dist-info/entry_points.txt +2 -0
- netcodex_agent_exporter-0.3.0.dist-info/licenses/LICENSE +21 -0
netcodex/__init__.py
ADDED
netcodex/cli.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from netcodex.renderers import DEFAULT_TEMPLATE_ID, MarkdownOptions
|
|
11
|
+
from netcodex.sources.paths import current_platform, path_matrix
|
|
12
|
+
from netcodex.sources.registry import create_source, registered
|
|
13
|
+
from netcodex.services import (
|
|
14
|
+
analyze_sources,
|
|
15
|
+
export_run,
|
|
16
|
+
SessionFilter,
|
|
17
|
+
format_names,
|
|
18
|
+
parse_when,
|
|
19
|
+
read_metadata_files,
|
|
20
|
+
scan_sources,
|
|
21
|
+
source_names,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(help="Export local agent conversations to Markdown, Quarkdown and PDF.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def scan(
|
|
29
|
+
sources: str = typer.Option("all", help="Comma-separated sources, or all."),
|
|
30
|
+
json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""List supported local conversation sessions without printing transcript bodies."""
|
|
33
|
+
|
|
34
|
+
payload = []
|
|
35
|
+
try:
|
|
36
|
+
payload = scan_sources(source_names(sources))
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
39
|
+
if json_output:
|
|
40
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
41
|
+
return
|
|
42
|
+
for item in payload:
|
|
43
|
+
typer.echo(f"{item['source']}: {item['count']} sessions")
|
|
44
|
+
for latest in item["latest"]:
|
|
45
|
+
typer.echo(f" - {latest['id']} | {latest['title']} | {latest['updated_at']}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def analyze(
|
|
50
|
+
source: str = typer.Option("auto", help="Source to analyze: auto, claude or codex."),
|
|
51
|
+
json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Analyze local conversation sources without printing transcript bodies."""
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
payload = analyze_sources(source)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
59
|
+
if json_output:
|
|
60
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
detected = payload["detected_source"] or "none"
|
|
64
|
+
typer.echo(f"detected source: {detected}")
|
|
65
|
+
for item in payload["sources"]:
|
|
66
|
+
typer.echo(f"{item['source']}: {item['count']} sessions")
|
|
67
|
+
for latest in item["latest"]:
|
|
68
|
+
typer.echo(f" - {latest['id']} | {latest['title']} | {latest['updated_at']}")
|
|
69
|
+
for warning in payload["warnings"]:
|
|
70
|
+
typer.echo(f"warning: {warning}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("sources")
|
|
74
|
+
def list_sources(
|
|
75
|
+
json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""List registered sources, whether their local store exists, and where they are read from."""
|
|
78
|
+
|
|
79
|
+
matrix = path_matrix()
|
|
80
|
+
payload = []
|
|
81
|
+
for info in registered():
|
|
82
|
+
source = create_source(info.name)
|
|
83
|
+
available = source.available()
|
|
84
|
+
diagnostics = getattr(source, "diagnostics", None)
|
|
85
|
+
payload.append(
|
|
86
|
+
{
|
|
87
|
+
"source": info.name,
|
|
88
|
+
"label": info.label,
|
|
89
|
+
"available": available,
|
|
90
|
+
"paths": [str(path) for path in matrix.get(info.name, [])],
|
|
91
|
+
"notes": diagnostics() if available and callable(diagnostics) else [],
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
if json_output:
|
|
95
|
+
typer.echo(json.dumps({"platform": current_platform(), "sources": payload}, indent=2))
|
|
96
|
+
return
|
|
97
|
+
for item in payload:
|
|
98
|
+
status = "found" if item["available"] else "not found"
|
|
99
|
+
typer.echo(f"{item['source']}: {status} ({item['label']})")
|
|
100
|
+
for note in item["notes"]:
|
|
101
|
+
typer.echo(f" note: {note}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def export(
|
|
106
|
+
source: str = typer.Option(
|
|
107
|
+
"auto", help="Source(s) to export: auto, all, or a comma-separated list (see `netcodex sources`)."
|
|
108
|
+
),
|
|
109
|
+
export_all: bool = typer.Option(False, "--all", help="Export every available source (same as --source all)."),
|
|
110
|
+
since: str | None = typer.Option(None, help="Only sessions active since a date (2026-06-01) or age (7d, 12h)."),
|
|
111
|
+
until: str | None = typer.Option(None, help="Only sessions last active before this date or age."),
|
|
112
|
+
workspace: str | None = typer.Option(None, help="Only sessions whose workspace path contains this text."),
|
|
113
|
+
thread: str | None = typer.Option(None, help="Conversation/session ID to export."),
|
|
114
|
+
formats: str = typer.Option("md", help="Comma-separated formats: md,qd,pdf."),
|
|
115
|
+
out: Path = typer.Option(Path("exports"), help="Output directory."),
|
|
116
|
+
limit: int = typer.Option(0, min=0, help="Latest sessions per source to export (0 = all)."),
|
|
117
|
+
include_paths: bool = typer.Option(False, help="Include full local source paths in metadata."),
|
|
118
|
+
template: str = typer.Option(DEFAULT_TEMPLATE_ID, help="Quarkdown template id for qd/pdf exports."),
|
|
119
|
+
json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
|
|
120
|
+
local: bool = typer.Option(False, "--local", help="Confirm this export stays in the local runtime."),
|
|
121
|
+
include_reasoning: bool = typer.Option(
|
|
122
|
+
True, "--include-reasoning/--no-reasoning", help="Include reasoning/thinking as collapsible blocks."
|
|
123
|
+
),
|
|
124
|
+
include_tool_output: bool = typer.Option(
|
|
125
|
+
True, "--include-tool-output/--no-tool-output", help="Include tool results under each tool call."
|
|
126
|
+
),
|
|
127
|
+
include_system_context: bool = typer.Option(
|
|
128
|
+
False,
|
|
129
|
+
"--include-system-context/--no-system-context",
|
|
130
|
+
help="Include injected environment/instructions/hook context (hidden by default).",
|
|
131
|
+
),
|
|
132
|
+
include_subagents: bool = typer.Option(
|
|
133
|
+
True, "--include-subagents/--no-subagents", help="Append subagent transcripts to their parent."
|
|
134
|
+
),
|
|
135
|
+
max_tool_output_lines: int = typer.Option(
|
|
136
|
+
200, min=0, help="Truncate each tool output after N lines (0 = no limit)."
|
|
137
|
+
),
|
|
138
|
+
max_tool_output_bytes: int = typer.Option(
|
|
139
|
+
32_000, min=0, help="Truncate each tool input/output after N bytes (0 = no limit)."
|
|
140
|
+
),
|
|
141
|
+
jobs: int = typer.Option(0, min=0, help="Worker processes for large exports (0 = auto, 1 = none)."),
|
|
142
|
+
incremental: bool = typer.Option(
|
|
143
|
+
True,
|
|
144
|
+
"--incremental/--no-incremental",
|
|
145
|
+
help="Skip sessions unchanged since the last export into --out (state in .netcodex-state.json).",
|
|
146
|
+
),
|
|
147
|
+
force: bool = typer.Option(False, "--force", help="Re-export every selected session."),
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Export selected conversations (one folder per conversation plus an index.md)."""
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
run = export_run(
|
|
153
|
+
"all" if export_all else source,
|
|
154
|
+
thread,
|
|
155
|
+
format_names(formats),
|
|
156
|
+
out,
|
|
157
|
+
limit,
|
|
158
|
+
include_paths,
|
|
159
|
+
template_id=template,
|
|
160
|
+
markdown_options=MarkdownOptions(
|
|
161
|
+
include_reasoning=include_reasoning,
|
|
162
|
+
include_tool_output=include_tool_output,
|
|
163
|
+
include_system_context=include_system_context,
|
|
164
|
+
include_subagents=include_subagents,
|
|
165
|
+
max_tool_output_lines=max_tool_output_lines,
|
|
166
|
+
max_tool_output_bytes=max_tool_output_bytes,
|
|
167
|
+
max_tool_input_bytes=max_tool_output_bytes,
|
|
168
|
+
),
|
|
169
|
+
session_filter=SessionFilter(since=parse_when(since), until=parse_when(until), workspace=workspace),
|
|
170
|
+
incremental=incremental,
|
|
171
|
+
force=force,
|
|
172
|
+
jobs=jobs,
|
|
173
|
+
)
|
|
174
|
+
except ValueError as exc:
|
|
175
|
+
typer.echo(str(exc), err=True)
|
|
176
|
+
raise typer.Exit(1) from exc
|
|
177
|
+
results = run.results
|
|
178
|
+
if json_output:
|
|
179
|
+
typer.echo(
|
|
180
|
+
json.dumps(
|
|
181
|
+
{
|
|
182
|
+
"local": local,
|
|
183
|
+
"selected": run.selected,
|
|
184
|
+
"skipped_unchanged": run.skipped,
|
|
185
|
+
"results": [
|
|
186
|
+
{
|
|
187
|
+
"source": result.conversation.source,
|
|
188
|
+
"conversation_id": result.conversation.id,
|
|
189
|
+
"target": str(result.target),
|
|
190
|
+
"steps": [step.model_dump(mode="json") for step in result.package.steps],
|
|
191
|
+
"manifest": result.package.manifest.model_dump(mode="json"),
|
|
192
|
+
}
|
|
193
|
+
for result in results
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
indent=2,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
for result in results:
|
|
201
|
+
typer.echo(
|
|
202
|
+
f"exported {result.conversation.source}:{result.conversation.id} -> {result.target}"
|
|
203
|
+
)
|
|
204
|
+
for step in result.package.steps:
|
|
205
|
+
typer.echo(f" {step.name}: {step.status} | {step.message}")
|
|
206
|
+
if run.skipped:
|
|
207
|
+
typer.echo(f"skipped {run.skipped} unchanged session(s); use --force to re-export")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def watch(
|
|
212
|
+
source: str = typer.Option("all", help="Source(s) to watch: all, auto or a comma-separated list."),
|
|
213
|
+
out: Path = typer.Option(Path("exports"), help="Output directory."),
|
|
214
|
+
interval: float = typer.Option(60.0, min=0.0, help="Seconds between polls."),
|
|
215
|
+
iterations: int = typer.Option(0, min=0, help="Stop after N polls (0 = run until interrupted)."),
|
|
216
|
+
since: str | None = typer.Option(None, help="Only sessions active since a date or age (7d)."),
|
|
217
|
+
workspace: str | None = typer.Option(None, help="Only sessions whose workspace contains this text."),
|
|
218
|
+
formats: str = typer.Option("md", help="Comma-separated formats: md,qd,pdf."),
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Poll the local stores and incrementally export new or changed conversations."""
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
format_list = format_names(formats)
|
|
224
|
+
session_filter = SessionFilter(since=parse_when(since), workspace=workspace)
|
|
225
|
+
except ValueError as exc:
|
|
226
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
227
|
+
poll = 0
|
|
228
|
+
typer.echo(f"watching {source} -> {out} every {interval:g}s (Ctrl+C to stop)")
|
|
229
|
+
try:
|
|
230
|
+
while True:
|
|
231
|
+
poll += 1
|
|
232
|
+
try:
|
|
233
|
+
run = export_run(source, None, format_list, out, 0, False, session_filter=session_filter, jobs=0)
|
|
234
|
+
typer.echo(
|
|
235
|
+
f"[{datetime.now().strftime('%H:%M:%S')}] poll {poll}: "
|
|
236
|
+
f"{len(run.results)} exported, {run.skipped} unchanged"
|
|
237
|
+
)
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
typer.echo(f"[{datetime.now().strftime('%H:%M:%S')}] poll {poll}: {exc}")
|
|
240
|
+
if iterations and poll >= iterations:
|
|
241
|
+
break
|
|
242
|
+
time.sleep(interval)
|
|
243
|
+
except KeyboardInterrupt:
|
|
244
|
+
typer.echo("stopped")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command()
|
|
248
|
+
def validate(path: Path = typer.Argument(Path("exports"))) -> None:
|
|
249
|
+
"""Validate generated export metadata files."""
|
|
250
|
+
|
|
251
|
+
if not path.exists():
|
|
252
|
+
raise typer.Exit(f"Path does not exist: {path}")
|
|
253
|
+
typer.echo(f"validated {read_metadata_files(path)} export metadata file(s)")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
LOOPBACK_BIND_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.command()
|
|
260
|
+
def ui(
|
|
261
|
+
host: str = typer.Option("127.0.0.1", help="Address to bind. Loopback only unless --allow-remote."),
|
|
262
|
+
port: int = typer.Option(0, min=0, max=65535, help="Port to listen on (0 = pick a free port)."),
|
|
263
|
+
out: Path = typer.Option(Path("exports"), help="Default export folder."),
|
|
264
|
+
open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the UI in the default browser."),
|
|
265
|
+
allow_remote: bool = typer.Option(
|
|
266
|
+
False,
|
|
267
|
+
"--allow-remote",
|
|
268
|
+
help="Allow binding to a non-loopback address. Anyone who can reach it and has the URL token "
|
|
269
|
+
"can read your conversations.",
|
|
270
|
+
),
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Start the local web UI (reads local stores directly; nothing is uploaded)."""
|
|
273
|
+
|
|
274
|
+
if host not in LOOPBACK_BIND_HOSTS and not allow_remote:
|
|
275
|
+
raise typer.BadParameter(
|
|
276
|
+
f"Refusing to bind to {host}: the UI exposes your local conversations. "
|
|
277
|
+
"Use 127.0.0.1, or pass --allow-remote if you really mean it.",
|
|
278
|
+
param_hint="--host",
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
import uvicorn
|
|
282
|
+
|
|
283
|
+
from netcodex.web.local_ui import create_local_app
|
|
284
|
+
except ImportError as exc:
|
|
285
|
+
typer.echo(
|
|
286
|
+
"The local UI needs the optional 'ui' extra: "
|
|
287
|
+
"pipx install 'netcodex-agent-exporter[ui]' (or uv tool install 'netcodex-agent-exporter[ui]').",
|
|
288
|
+
err=True,
|
|
289
|
+
)
|
|
290
|
+
raise typer.Exit(1) from exc
|
|
291
|
+
|
|
292
|
+
import secrets
|
|
293
|
+
import socket
|
|
294
|
+
import threading
|
|
295
|
+
import webbrowser
|
|
296
|
+
|
|
297
|
+
if port == 0:
|
|
298
|
+
with socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET) as probe:
|
|
299
|
+
probe.bind((host, 0))
|
|
300
|
+
port = probe.getsockname()[1]
|
|
301
|
+
token = secrets.token_urlsafe(24)
|
|
302
|
+
application = create_local_app(out.resolve(), token=token, allow_remote=allow_remote)
|
|
303
|
+
shown_host = f"[{host}]" if ":" in host else host
|
|
304
|
+
url = f"http://{shown_host}:{port}/#token={token}"
|
|
305
|
+
typer.echo(f"NetCodex UI: {url}")
|
|
306
|
+
typer.echo("Press Ctrl+C to stop.")
|
|
307
|
+
if open_browser:
|
|
308
|
+
threading.Timer(1.0, webbrowser.open, args=(url,)).start()
|
|
309
|
+
uvicorn.run(application, host=host, port=port, log_level="warning")
|
netcodex/incremental.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Incremental export state.
|
|
2
|
+
|
|
3
|
+
``<out>/.netcodex-state.json`` remembers, per ``<source>:<id>``, a fingerprint of the source
|
|
4
|
+
files (size + mtime of the session file and any subagent files) together with a hash of the
|
|
5
|
+
export options, and the folder the conversation was written to. Unchanged sessions are
|
|
6
|
+
skipped on the next run; ``--force`` re-exports everything.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from netcodex.models import SourceSession
|
|
20
|
+
|
|
21
|
+
STATE_FILE = ".netcodex-state.json"
|
|
22
|
+
STATE_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def session_fingerprint(session: SourceSession) -> str:
|
|
26
|
+
"""Cheap change detector: size and mtime of every file the session is parsed from."""
|
|
27
|
+
|
|
28
|
+
paths = [session.path]
|
|
29
|
+
subagents = session.path.parent / session.path.stem / "subagents"
|
|
30
|
+
if subagents.is_dir():
|
|
31
|
+
paths.extend(sorted(subagents.iterdir()))
|
|
32
|
+
extra = session.metadata.get("fingerprint_paths")
|
|
33
|
+
if isinstance(extra, str):
|
|
34
|
+
paths.extend(Path(item) for item in extra.split("|") if item)
|
|
35
|
+
digest = hashlib.sha256(session.title.encode("utf-8", errors="replace"))
|
|
36
|
+
for path in paths:
|
|
37
|
+
try:
|
|
38
|
+
stat = path.stat()
|
|
39
|
+
except OSError:
|
|
40
|
+
digest.update(f"{path.name}:missing".encode())
|
|
41
|
+
continue
|
|
42
|
+
digest.update(f"{path.name}:{stat.st_size}:{stat.st_mtime_ns}".encode())
|
|
43
|
+
return digest.hexdigest()[:32]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def options_fingerprint(options: dict[str, Any]) -> str:
|
|
47
|
+
return hashlib.sha256(json.dumps(options, sort_keys=True, default=str).encode()).hexdigest()[:16]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ExportState:
|
|
52
|
+
out: Path
|
|
53
|
+
entries: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def load(cls, out: Path) -> "ExportState":
|
|
57
|
+
path = out / STATE_FILE
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
return cls(out=out)
|
|
62
|
+
if not isinstance(data, dict) or data.get("version") != STATE_VERSION:
|
|
63
|
+
return cls(out=out)
|
|
64
|
+
entries = data.get("entries")
|
|
65
|
+
return cls(out=out, entries=entries if isinstance(entries, dict) else {})
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def key(session: SourceSession) -> str:
|
|
69
|
+
return f"{session.source}:{session.id}"
|
|
70
|
+
|
|
71
|
+
def is_current(self, session: SourceSession, fingerprint: str, options: str) -> bool:
|
|
72
|
+
entry = self.entries.get(self.key(session))
|
|
73
|
+
if not entry or entry.get("fingerprint") != fingerprint or entry.get("options") != options:
|
|
74
|
+
return False
|
|
75
|
+
folder = entry.get("folder")
|
|
76
|
+
return bool(folder) and (self.out / str(folder)).is_dir()
|
|
77
|
+
|
|
78
|
+
def record(self, session: SourceSession, fingerprint: str, options: str, target: Path) -> None:
|
|
79
|
+
key = self.key(session)
|
|
80
|
+
folder = target.relative_to(self.out).as_posix()
|
|
81
|
+
previous = self.entries.get(key, {}).get("folder")
|
|
82
|
+
if previous and previous != folder:
|
|
83
|
+
self._remove_stale_folder(str(previous))
|
|
84
|
+
self.entries[key] = {
|
|
85
|
+
"fingerprint": fingerprint,
|
|
86
|
+
"options": options,
|
|
87
|
+
"folder": folder,
|
|
88
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def save(self) -> Path:
|
|
92
|
+
self.out.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
path = self.out / STATE_FILE
|
|
94
|
+
payload = {"version": STATE_VERSION, "entries": dict(sorted(self.entries.items()))}
|
|
95
|
+
tmp = path.with_suffix(".tmp")
|
|
96
|
+
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
97
|
+
tmp.replace(path)
|
|
98
|
+
return path
|
|
99
|
+
|
|
100
|
+
def _remove_stale_folder(self, folder: str) -> None:
|
|
101
|
+
# A renamed conversation (new title -> new folder) must not leave a duplicate behind.
|
|
102
|
+
# Only folders recorded by this state file and inside the export root are removed.
|
|
103
|
+
stale = (self.out / folder).resolve()
|
|
104
|
+
root = self.out.resolve()
|
|
105
|
+
if stale != root and root in stale.parents and (stale / "metadata.json").exists():
|
|
106
|
+
shutil.rmtree(stale, ignore_errors=True)
|
netcodex/models.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
SourceName = str # registry key, e.g. "codex", "claude", "copilot-chat"
|
|
10
|
+
Role = Literal["user", "assistant", "system", "tool", "summary"]
|
|
11
|
+
PartKind = Literal[
|
|
12
|
+
"text",
|
|
13
|
+
"code",
|
|
14
|
+
"tool_call",
|
|
15
|
+
"tool_result",
|
|
16
|
+
"reasoning",
|
|
17
|
+
"image",
|
|
18
|
+
"attachment",
|
|
19
|
+
"system_context",
|
|
20
|
+
"summary",
|
|
21
|
+
"tool_event",
|
|
22
|
+
]
|
|
23
|
+
MetadataValue = str | int | float | bool | None
|
|
24
|
+
PipelineStepName = Literal["analyze", "parse", "render", "package"]
|
|
25
|
+
PipelineStepStatus = Literal["pending", "running", "success", "warning", "error"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MessagePart(BaseModel):
|
|
29
|
+
"""One block of a turn.
|
|
30
|
+
|
|
31
|
+
``tool_call`` parts carry the tool ``name`` and ``call_id``; their ``content`` is the
|
|
32
|
+
arguments/input. ``tool_result`` parts carry the matching ``call_id`` and the output as
|
|
33
|
+
``content``. ``image``/``attachment`` parts are placeholders: ``content`` is a short label
|
|
34
|
+
and ``metadata`` describes the original payload (media type, size, file name...).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
kind: PartKind = "text"
|
|
38
|
+
content: str = ""
|
|
39
|
+
language: str | None = None
|
|
40
|
+
name: str | None = None
|
|
41
|
+
call_id: str | None = None
|
|
42
|
+
is_error: bool = False
|
|
43
|
+
metadata: dict[str, MetadataValue] = Field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Turn(BaseModel):
|
|
47
|
+
id: str
|
|
48
|
+
role: Role
|
|
49
|
+
timestamp: datetime | None = None
|
|
50
|
+
parts: list[MessagePart] = Field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def text(self) -> str:
|
|
54
|
+
return "\n\n".join(part.content for part in self.parts if part.content)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Conversation(BaseModel):
|
|
58
|
+
id: str
|
|
59
|
+
source: SourceName
|
|
60
|
+
title: str
|
|
61
|
+
created_at: datetime | None = None
|
|
62
|
+
updated_at: datetime | None = None
|
|
63
|
+
ended_at: datetime | None = None
|
|
64
|
+
workspace: str | None = None
|
|
65
|
+
tool: str | None = None
|
|
66
|
+
originator: str | None = None
|
|
67
|
+
model: str | None = None
|
|
68
|
+
git_branch: str | None = None
|
|
69
|
+
parent_id: str | None = None
|
|
70
|
+
agent_name: str | None = None
|
|
71
|
+
turns: list[Turn] = Field(default_factory=list)
|
|
72
|
+
subagents: list["Conversation"] = Field(default_factory=list)
|
|
73
|
+
metadata: dict[str, MetadataValue] = Field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def started_at(self) -> datetime | None:
|
|
77
|
+
if self.created_at:
|
|
78
|
+
return self.created_at
|
|
79
|
+
stamps = [turn.timestamp for turn in self.turns if turn.timestamp]
|
|
80
|
+
return min(stamps) if stamps else None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def last_activity_at(self) -> datetime | None:
|
|
84
|
+
if self.ended_at:
|
|
85
|
+
return self.ended_at
|
|
86
|
+
stamps = [turn.timestamp for turn in self.turns if turn.timestamp]
|
|
87
|
+
return max(stamps) if stamps else self.updated_at
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SourceSession(BaseModel):
|
|
91
|
+
id: str
|
|
92
|
+
source: SourceName
|
|
93
|
+
title: str
|
|
94
|
+
path: Path
|
|
95
|
+
updated_at: datetime | None = None
|
|
96
|
+
metadata: dict[str, MetadataValue] = Field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ExportMetadata(BaseModel):
|
|
100
|
+
exported_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
101
|
+
source: SourceName
|
|
102
|
+
conversation_id: str
|
|
103
|
+
formats: list[str]
|
|
104
|
+
template: str | None = None
|
|
105
|
+
include_paths: bool = False
|
|
106
|
+
title: str | None = None
|
|
107
|
+
tool: str | None = None
|
|
108
|
+
model: str | None = None
|
|
109
|
+
started_at: datetime | None = None
|
|
110
|
+
ended_at: datetime | None = None
|
|
111
|
+
turn_count: int | None = None
|
|
112
|
+
parent_id: str | None = None
|
|
113
|
+
subagent_count: int = 0
|
|
114
|
+
markdown: str | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PipelineStep(BaseModel):
|
|
118
|
+
name: PipelineStepName
|
|
119
|
+
status: PipelineStepStatus
|
|
120
|
+
label: str
|
|
121
|
+
message: str
|
|
122
|
+
warnings: list[str] = Field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ExportArtifact(BaseModel):
|
|
126
|
+
path: str
|
|
127
|
+
bytes: int
|
|
128
|
+
sha256: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ExportManifest(BaseModel):
|
|
132
|
+
schema_version: str = "0.1"
|
|
133
|
+
job_id: str
|
|
134
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
135
|
+
source: SourceName
|
|
136
|
+
session_count: int
|
|
137
|
+
conversation_id: str
|
|
138
|
+
conversation_title: str
|
|
139
|
+
formats: list[str]
|
|
140
|
+
template: str
|
|
141
|
+
template_label: str
|
|
142
|
+
privacy: dict[str, bool]
|
|
143
|
+
steps: list[PipelineStep]
|
|
144
|
+
artifacts: list[ExportArtifact]
|
|
145
|
+
warnings: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from netcodex.renderers.markdown import MarkdownOptions, render_index, render_markdown, visible_turns
|
|
2
|
+
from netcodex.renderers.quarkdown import (
|
|
3
|
+
DEFAULT_TEMPLATE_ID,
|
|
4
|
+
render_quarkdown,
|
|
5
|
+
resolve_template,
|
|
6
|
+
template_choices,
|
|
7
|
+
template_label,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DEFAULT_TEMPLATE_ID",
|
|
12
|
+
"MarkdownOptions",
|
|
13
|
+
"render_index",
|
|
14
|
+
"visible_turns",
|
|
15
|
+
"render_markdown",
|
|
16
|
+
"render_quarkdown",
|
|
17
|
+
"resolve_template",
|
|
18
|
+
"template_choices",
|
|
19
|
+
"template_label",
|
|
20
|
+
]
|