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 ADDED
@@ -0,0 +1,6 @@
1
+ """NetCodex agent conversation exporter."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.3.0"
6
+
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")
@@ -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
+ ]