thrdi 0.0.1__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.
thirdeye/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ try:
2
+ from thirdeye._version import version as __version__
3
+ except ImportError:
4
+ __version__ = "0.0.0+unknown"
thirdeye/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from thirdeye.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
thirdeye/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = None
thirdeye/cli.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from thirdeye import __version__
6
+ from thirdeye.commands.add import add, remove
7
+ from thirdeye.commands.ingest import ingest
8
+ from thirdeye.commands.reads import event, events, list_sessions, search, show, stats, tail
9
+ from thirdeye.commands.tags import tag, tags
10
+
11
+
12
+ @click.group(name="thirdeye", help="Trace agentic CLIs to a unified local store.")
13
+ @click.version_option(__version__, prog_name="thirdeye")
14
+ def main() -> None:
15
+ pass
16
+
17
+
18
+ main.add_command(add)
19
+ main.add_command(remove)
20
+ main.add_command(ingest)
21
+ main.add_command(list_sessions)
22
+ main.add_command(show)
23
+ main.add_command(events)
24
+ main.add_command(tail)
25
+ main.add_command(event)
26
+ main.add_command(search)
27
+ main.add_command(tag)
28
+ main.add_command(tags)
29
+ main.add_command(stats)
thirdeye/codec.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import msgpack
6
+ import zstandard as zstd
7
+
8
+ _compressor = zstd.ZstdCompressor()
9
+ _decompressor = zstd.ZstdDecompressor()
10
+
11
+
12
+ def encode_event(event: dict[str, Any]) -> bytes:
13
+ packed = msgpack.packb(event, use_bin_type=True)
14
+ return _compressor.compress(packed)
15
+
16
+
17
+ def decode_event(frame: bytes) -> dict[str, Any]:
18
+ packed = _decompressor.decompress(frame)
19
+ return msgpack.unpackb(packed, raw=False)
File without changes
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from thirdeye.platforms.base import Platform
6
+ from thirdeye.platforms.claude.install import ClaudePlatform
7
+ from thirdeye.platforms.codex.install import CodexPlatform
8
+ from thirdeye.platforms.gemini.install import GeminiPlatform
9
+
10
+ PLATFORMS: dict[str, type[Platform]] = {
11
+ "claude": ClaudePlatform,
12
+ "gemini": GeminiPlatform,
13
+ "codex": CodexPlatform,
14
+ }
15
+
16
+
17
+ def _platform_options(fn):
18
+ fn = click.option("--codex", "platform_flag", flag_value="codex", help="Codex CLI.")(fn)
19
+ fn = click.option("--gemini", "platform_flag", flag_value="gemini", help="Gemini CLI.")(fn)
20
+ fn = click.option("--claude", "platform_flag", flag_value="claude", help="Claude Code.")(fn)
21
+ return fn
22
+
23
+
24
+ def _resolve_platform(platform_flag: str | None) -> Platform:
25
+ if not platform_flag:
26
+ raise click.UsageError("Pick a platform: --claude, --gemini, --codex")
27
+ return PLATFORMS[platform_flag]()
28
+
29
+
30
+ @click.command(help="Install tracing hooks for an agentic platform.")
31
+ @_platform_options
32
+ def add(platform_flag: str | None) -> None:
33
+ platform = _resolve_platform(platform_flag)
34
+ platform.install()
35
+ click.echo(f"Installed tracing for {platform.display_name}")
36
+
37
+
38
+ @click.command(help="Remove tracing hooks for an agentic platform.")
39
+ @_platform_options
40
+ def remove(platform_flag: str | None) -> None:
41
+ platform = _resolve_platform(platform_flag)
42
+ platform.uninstall()
43
+ click.echo(f"Removed tracing for {platform.display_name}")
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ import click
7
+
8
+ from thirdeye.config import Config
9
+ from thirdeye.ids import new_ulid
10
+ from thirdeye.store import Store
11
+
12
+
13
+ @click.command(help="Read newline-delimited JSON events from stdin and append them to a session.")
14
+ @click.option("--platform", required=True, help="Platform name (e.g. claude, cursor).")
15
+ @click.option("--session-id", default=None, help="Session ID. Generated if omitted.")
16
+ @click.option("--cwd", default=None, help="Working directory for the session.")
17
+ def ingest(platform: str, session_id: str | None, cwd: str | None) -> None:
18
+ sid = session_id or new_ulid()
19
+ cwd_val = cwd or "."
20
+ store = Store(Config.load())
21
+ written = 0
22
+ with store.open_session(sid, platform=platform, cwd=cwd_val) as w:
23
+ for line in sys.stdin:
24
+ line = line.strip()
25
+ if not line:
26
+ continue
27
+ obj = json.loads(line)
28
+ t = obj.pop("t", "event")
29
+ data = obj.pop("data", None)
30
+ w.append(t, data)
31
+ written += 1
32
+ click.echo(f"{sid}\t{written} events", err=True)
33
+ click.echo(sid)
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+
6
+ import click
7
+
8
+ from thirdeye.config import Config
9
+ from thirdeye.render import render_event_jsonl, render_event_terse, render_event_tree
10
+ from thirdeye.search import search as search_fn
11
+ from thirdeye.store import Store
12
+ from thirdeye.tags import validate_tag
13
+ from thirdeye.timeparse import parse_when
14
+
15
+
16
+ def _store() -> Store:
17
+ return Store(Config.load())
18
+
19
+
20
+ def _emit_events(events, *, json_mode: bool, tree_mode: bool, width: int) -> None:
21
+ for event in events:
22
+ if json_mode:
23
+ click.echo(render_event_jsonl(event))
24
+ elif tree_mode:
25
+ click.echo(render_event_tree(event))
26
+ else:
27
+ click.echo(render_event_terse(event, width=width))
28
+
29
+
30
+ def _resolve_platform(platform: str | None, harness: str | None) -> str | None:
31
+ if platform is not None and harness is not None and platform != harness:
32
+ raise click.ClickException("--harness and --platform disagree")
33
+ return platform if platform is not None else harness
34
+
35
+
36
+ def _parse_when_or_die(value: str | None, flag: str):
37
+ if value is None:
38
+ return None
39
+ try:
40
+ return parse_when(value)
41
+ except ValueError as e:
42
+ raise click.ClickException(f"could not parse {flag} {value!r}: {e}") from e
43
+
44
+
45
+ def _resolve_tags(tags: tuple[str, ...]) -> set[str] | None:
46
+ if not tags:
47
+ return None
48
+ resolved: set[str] = set()
49
+ for t in tags:
50
+ try:
51
+ resolved.add(validate_tag(t))
52
+ except ValueError as e:
53
+ raise click.ClickException(str(e)) from e
54
+ return resolved
55
+
56
+
57
+ @click.command("list", help="List recorded sessions.")
58
+ @click.option("--platform", default=None)
59
+ @click.option("--harness", default=None, help="Alias for --platform.")
60
+ @click.option("--cwd", default=None)
61
+ @click.option("--status", default=None)
62
+ @click.option("--tag", "tags", multiple=True, help="Filter by tag (repeatable, AND'd).")
63
+ @click.option("--since", default=None, help="Include sessions active at/after this time.")
64
+ @click.option("--until", default=None, help="Include sessions active at/before this time.")
65
+ @click.option("--json", "json_mode", is_flag=True, help="JSON-per-line output (default).")
66
+ @click.option("--tree", is_flag=True, help="Human-readable tree output.")
67
+ def list_sessions(platform, harness, cwd, status, tags, since, until, json_mode, tree):
68
+ resolved_platform = _resolve_platform(platform, harness)
69
+ resolved_tags = _resolve_tags(tags)
70
+ since_dt = _parse_when_or_die(since, "--since")
71
+ until_dt = _parse_when_or_die(until, "--until")
72
+
73
+ store = _store()
74
+ for meta in store.list_sessions(
75
+ platform=resolved_platform,
76
+ cwd=cwd,
77
+ status=status,
78
+ tags=resolved_tags,
79
+ since=since_dt,
80
+ until=until_dt,
81
+ ):
82
+ if tree:
83
+ click.echo(
84
+ f"{meta.session_id} [{meta.platform}] {meta.cwd} "
85
+ f"{meta.status} events={meta.event_count}"
86
+ )
87
+ else:
88
+ click.echo(json.dumps(asdict(meta), default=str, separators=(",", ":")))
89
+
90
+
91
+ @click.command(help="Print events for a session.")
92
+ @click.argument("session_prefix")
93
+ @click.option("--type", "types", multiple=True, help="Filter by event type (repeatable).")
94
+ @click.option("--json", "json_mode", is_flag=True)
95
+ @click.option("--tree", "tree_mode", is_flag=True)
96
+ @click.option("--width", default=120, type=int, help="Line width (0 = unlimited).")
97
+ def events(session_prefix, types, json_mode, tree_mode, width):
98
+ reader = _store().reader(session_prefix)
99
+ iter_ = reader.iter_events(types=set(types) if types else None)
100
+ _emit_events(iter_, json_mode=json_mode, tree_mode=tree_mode, width=width)
101
+
102
+
103
+ @click.command(help="Show all events for a session (alias of `events`).")
104
+ @click.argument("session_prefix")
105
+ @click.option("--json", "json_mode", is_flag=True)
106
+ @click.option("--tree", "tree_mode", is_flag=True)
107
+ @click.option("--width", default=120, type=int)
108
+ def show(session_prefix, json_mode, tree_mode, width):
109
+ reader = _store().reader(session_prefix)
110
+ _emit_events(reader.iter_events(), json_mode=json_mode, tree_mode=tree_mode, width=width)
111
+
112
+
113
+ @click.command(help="Last N events for a session.")
114
+ @click.argument("session_prefix")
115
+ @click.option("-n", "n", default=10, type=int)
116
+ @click.option("--json", "json_mode", is_flag=True)
117
+ @click.option("--tree", "tree_mode", is_flag=True)
118
+ @click.option("--width", default=120, type=int)
119
+ def tail(session_prefix, n, json_mode, tree_mode, width):
120
+ reader = _store().reader(session_prefix)
121
+ all_events = list(reader.iter_events())
122
+ _emit_events(all_events[-n:], json_mode=json_mode, tree_mode=tree_mode, width=width)
123
+
124
+
125
+ @click.command(help="Print a single event by seq, fully expanded.")
126
+ @click.argument("session_prefix")
127
+ @click.argument("seq", type=int)
128
+ @click.option("--field", default=None, help="Print only one field of `data`.")
129
+ def event(session_prefix, seq, field):
130
+ e = _store().reader(session_prefix).get_event(seq)
131
+ if field is not None:
132
+ data = e.get("data")
133
+ if isinstance(data, dict) and field in data:
134
+ value = data[field]
135
+ if isinstance(value, (str, int, float, bool)):
136
+ click.echo(value)
137
+ else:
138
+ click.echo(json.dumps(value, default=str, ensure_ascii=False))
139
+ return
140
+ raise click.ClickException(f"field {field!r} not found in event data")
141
+ click.echo(json.dumps(e, default=str, ensure_ascii=False, indent=2))
142
+
143
+
144
+ @click.command(help="Search across sessions.")
145
+ @click.argument("query")
146
+ @click.option("--platform", default=None)
147
+ @click.option("--harness", default=None, help="Alias for --platform.")
148
+ @click.option("--cwd", default=None)
149
+ @click.option("--tag", "tags", multiple=True, help="Filter by tag (repeatable, AND'd).")
150
+ @click.option("--since", default=None, help="Include events at/after this time.")
151
+ @click.option("--until", default=None, help="Include events at/before this time.")
152
+ def search(query, platform, harness, cwd, tags, since, until):
153
+ resolved_platform = _resolve_platform(platform, harness)
154
+ resolved_tags = _resolve_tags(tags)
155
+ since_dt = _parse_when_or_die(since, "--since")
156
+ until_dt = _parse_when_or_die(until, "--until")
157
+
158
+ for hit in search_fn(
159
+ _store(),
160
+ query,
161
+ platform=resolved_platform,
162
+ cwd=cwd,
163
+ tags=resolved_tags,
164
+ since=since_dt,
165
+ until=until_dt,
166
+ ):
167
+ click.echo(json.dumps(asdict(hit), default=str, ensure_ascii=False, separators=(",", ":")))
168
+
169
+
170
+ @click.command(help="Stats for a session, or global if no id given.")
171
+ @click.argument("session_prefix", required=False)
172
+ def stats(session_prefix):
173
+ store = _store()
174
+ s = store.stats(session_id=session_prefix) if session_prefix else store.stats()
175
+ click.echo(json.dumps(s, default=str, ensure_ascii=False))
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ from thirdeye.config import Config
8
+ from thirdeye.meta import read_meta, write_meta
9
+ from thirdeye.paths import meta_path, session_dir
10
+ from thirdeye.store import Store
11
+ from thirdeye.tags import TagStore, validate_tag
12
+
13
+
14
+ def _store() -> Store:
15
+ return Store(Config.load())
16
+
17
+
18
+ def _parse_tags(raw: str) -> list[str]:
19
+ out: list[str] = []
20
+ for piece in raw.split(","):
21
+ orig = piece.strip()
22
+ if not orig:
23
+ continue
24
+ try:
25
+ out.append(validate_tag(orig.lower()))
26
+ except ValueError:
27
+ raise click.ClickException(
28
+ f"invalid tag '{orig}': must match [a-z0-9_-]{{1,64}}"
29
+ ) from None
30
+ return out
31
+
32
+
33
+ @click.command("tag", help="Add, remove, or list tags on session events.")
34
+ @click.argument("session_prefix")
35
+ @click.argument("seq", required=False, type=int)
36
+ @click.option("--add", "add_raw", default=None, help="Comma-separated tags to add.")
37
+ @click.option("--remove", "remove_raw", default=None, help="Comma-separated tags to remove.")
38
+ @click.option("--list", "list_mode", is_flag=True, help="List tagged events in session.")
39
+ def tag(session_prefix, seq, add_raw, remove_raw, list_mode):
40
+ store = _store()
41
+ try:
42
+ platform, sid = store.resolve_session_id(session_prefix)
43
+ except ValueError as e:
44
+ raise click.ClickException(str(e)) from None
45
+
46
+ sd = session_dir(store.config.root, platform, sid)
47
+ mp = meta_path(sd)
48
+
49
+ if list_mode:
50
+ if add_raw is not None or remove_raw is not None:
51
+ raise click.ClickException("--list cannot be combined with --add or --remove")
52
+ if seq is not None:
53
+ raise click.ClickException("--list does not take a seq argument")
54
+ ts = TagStore(sd)
55
+ for s in sorted(ts.all_tags().keys()):
56
+ tags_sorted = sorted(ts.tags_for(s))
57
+ click.echo(json.dumps({"seq": s, "tags": tags_sorted}, separators=(",", ":")))
58
+ return
59
+
60
+ if add_raw is None and remove_raw is None:
61
+ raise click.ClickException("provide --add, --remove, or --list")
62
+ if seq is None:
63
+ raise click.ClickException("SEQ is required for --add/--remove")
64
+
65
+ meta = read_meta(mp)
66
+ if meta is None:
67
+ raise click.ClickException(f"no meta for session {sid}")
68
+ n = meta.event_count
69
+ if seq < 0 or seq >= n:
70
+ raise click.ClickException(f"seq {seq} not found in session {sid} (event_count={n})")
71
+
72
+ ts = TagStore(sd)
73
+ if add_raw is not None:
74
+ for t in _parse_tags(add_raw):
75
+ ts.add(seq, t)
76
+ if remove_raw is not None:
77
+ for t in _parse_tags(remove_raw):
78
+ ts.remove(seq, t)
79
+
80
+ meta.tag_count = ts.tagged_seq_count()
81
+ write_meta(mp, meta)
82
+
83
+
84
+ @click.command("tags", help="Show global tag inventory across all sessions.")
85
+ @click.option("--json", "json_mode", is_flag=True, help="Emit one JSON object per line.")
86
+ def tags(json_mode):
87
+ store = _store()
88
+ event_counts: dict[str, int] = {}
89
+ session_counts: dict[str, int] = {}
90
+ for meta in store.list_sessions():
91
+ sd = session_dir(store.config.root, meta.platform, meta.session_id)
92
+ ts = TagStore(sd)
93
+ per_tag_events: dict[str, int] = {}
94
+ for tag_set in ts.all_tags().values():
95
+ for t in tag_set:
96
+ per_tag_events[t] = per_tag_events.get(t, 0) + 1
97
+ for t, c in per_tag_events.items():
98
+ event_counts[t] = event_counts.get(t, 0) + c
99
+ session_counts[t] = session_counts.get(t, 0) + 1
100
+
101
+ rows = sorted(event_counts.items(), key=lambda kv: (-kv[1], kv[0]))
102
+ for tag_name, ev_count in rows:
103
+ sess_count = session_counts[tag_name]
104
+ if json_mode:
105
+ click.echo(
106
+ json.dumps(
107
+ {"tag": tag_name, "events": ev_count, "sessions": sess_count},
108
+ separators=(",", ":"),
109
+ )
110
+ )
111
+ else:
112
+ click.echo(f"{tag_name}\t{ev_count}\t{sess_count}")
thirdeye/config.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ def default_root() -> Path:
9
+ env = os.environ.get("THIRDEYE_HOME")
10
+ if env:
11
+ return Path(env).expanduser()
12
+ return Path.home() / ".thirdeye"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Config:
17
+ root: Path
18
+
19
+ @classmethod
20
+ def load(cls) -> Config:
21
+ return cls(root=default_root())
22
+
23
+ @property
24
+ def traces_dir(self) -> Path:
25
+ return self.root / "traces"
26
+
27
+ @property
28
+ def config_file(self) -> Path:
29
+ return self.root / "config.yaml"
30
+
31
+
32
+ def load() -> Config:
33
+ return Config.load()
thirdeye/ids.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+
6
+ # Crockford base32 (excludes I, L, O, U)
7
+ _ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
8
+
9
+
10
+ def new_ulid() -> str:
11
+ """26-char Crockford-base32 ULID: 48 bits ms timestamp + 80 bits randomness."""
12
+ ts_ms = int(time.time() * 1000) & ((1 << 48) - 1)
13
+ rand = int.from_bytes(os.urandom(10), "big")
14
+ val = (ts_ms << 80) | rand
15
+ out = []
16
+ for _ in range(26):
17
+ out.append(_ALPHABET[val & 0x1F])
18
+ val >>= 5
19
+ return "".join(reversed(out))
20
+
21
+
22
+ def resolve_prefix(prefix: str, candidates: list[str]) -> str:
23
+ """Return the unique candidate starting with `prefix`. Raise ValueError if 0 or >1 match."""
24
+ matches = [c for c in candidates if c.startswith(prefix)]
25
+ if not matches:
26
+ raise ValueError(f"no session matching prefix {prefix!r}")
27
+ if len(matches) > 1:
28
+ raise ValueError(f"prefix {prefix!r} ambiguous: {matches!r}")
29
+ return matches[0]
thirdeye/index.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import struct
5
+ from pathlib import Path
6
+
7
+ import zstandard as zstd
8
+
9
+ _ENTRY_FMT = "<Q"
10
+ _ENTRY_SIZE = 8
11
+
12
+
13
+ class IndexWriter:
14
+ def __init__(self, path: Path) -> None:
15
+ self.path = path
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ self._fp = open(path, "ab")
18
+
19
+ def append(self, offset: int) -> None:
20
+ self._fp.write(struct.pack(_ENTRY_FMT, offset))
21
+ self._fp.flush()
22
+ os.fsync(self._fp.fileno())
23
+
24
+ def close(self) -> None:
25
+ self._fp.close()
26
+
27
+ def __enter__(self) -> IndexWriter:
28
+ return self
29
+
30
+ def __exit__(self, *exc: object) -> None:
31
+ self.close()
32
+
33
+
34
+ class IndexReader:
35
+ def __init__(self, path: Path) -> None:
36
+ self.path = path
37
+
38
+ def count(self) -> int:
39
+ if not self.path.exists():
40
+ return 0
41
+ return self.path.stat().st_size // _ENTRY_SIZE
42
+
43
+ def get(self, seq: int) -> int:
44
+ if seq < 0 or seq >= self.count():
45
+ raise IndexError(f"seq {seq} out of range (count={self.count()})")
46
+ with open(self.path, "rb") as fp:
47
+ fp.seek(seq * _ENTRY_SIZE)
48
+ return struct.unpack(_ENTRY_FMT, fp.read(_ENTRY_SIZE))[0]
49
+
50
+ def all_offsets(self) -> list[int]:
51
+ if not self.path.exists():
52
+ return []
53
+ with open(self.path, "rb") as fp:
54
+ data = fp.read()
55
+ return [
56
+ struct.unpack(_ENTRY_FMT, data[i : i + _ENTRY_SIZE])[0]
57
+ for i in range(0, len(data), _ENTRY_SIZE)
58
+ ]
59
+
60
+
61
+ def rebuild_index(events_log: Path, idx_path: Path) -> int:
62
+ """Walk events.alog frame-by-frame; rewrite idx_path. Returns event count."""
63
+ if idx_path.exists():
64
+ idx_path.unlink()
65
+ if not events_log.exists() or events_log.stat().st_size == 0:
66
+ idx_path.touch()
67
+ return 0
68
+
69
+ data = events_log.read_bytes()
70
+ offsets: list[int] = []
71
+ pos = 0
72
+ while pos < len(data):
73
+ offsets.append(pos)
74
+ dobj = zstd.ZstdDecompressor().decompressobj()
75
+ try:
76
+ dobj.decompress(data[pos:])
77
+ except zstd.ZstdError:
78
+ offsets.pop()
79
+ break
80
+ remaining = len(dobj.unused_data)
81
+ pos = len(data) - remaining
82
+
83
+ with IndexWriter(idx_path) as w:
84
+ for off in offsets:
85
+ w.append(off)
86
+ return len(offsets)
thirdeye/meta.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import asdict, dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ SCHEMA_VERSION = 2
11
+
12
+
13
+ @dataclass
14
+ class SessionMeta:
15
+ session_id: str
16
+ platform: str
17
+ cwd: str
18
+ started_at: str
19
+ ended_at: str | None
20
+ status: str # "open" | "closed" | "stale"
21
+ event_count: int
22
+ last_seq: int # -1 if no events yet
23
+ last_ts: str | None
24
+ tag_count: int = 0
25
+ extra: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ def write_meta(path: Path, meta: SessionMeta) -> None:
29
+ payload = {"schema_version": SCHEMA_VERSION, **asdict(meta)}
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ tmp = path.with_suffix(path.suffix + ".tmp")
32
+ with open(tmp, "w") as f:
33
+ yaml.safe_dump(payload, f, sort_keys=False)
34
+ f.flush()
35
+ os.fsync(f.fileno())
36
+ os.replace(tmp, path)
37
+
38
+
39
+ def read_meta(path: Path) -> SessionMeta | None:
40
+ if not path.exists():
41
+ return None
42
+ with open(path) as f:
43
+ raw = yaml.safe_load(f) or {}
44
+ raw.pop("schema_version", None)
45
+ raw.setdefault("extra", {})
46
+ raw.setdefault("tag_count", 0)
47
+ return SessionMeta(**raw)
thirdeye/paths.py ADDED
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def sessions_root(thirdeye_home: Path) -> Path:
5
+ return thirdeye_home / "traces"
6
+
7
+
8
+ def platform_dir(thirdeye_home: Path, platform: str) -> Path:
9
+ return sessions_root(thirdeye_home) / platform
10
+
11
+
12
+ def session_dir(thirdeye_home: Path, platform: str, session_id: str) -> Path:
13
+ return platform_dir(thirdeye_home, platform) / session_id
14
+
15
+
16
+ def events_path(session_dir_: Path) -> Path:
17
+ return session_dir_ / "events.alog"
18
+
19
+
20
+ def index_path(session_dir_: Path) -> Path:
21
+ return session_dir_ / "events.idx"
22
+
23
+
24
+ def meta_path(session_dir_: Path) -> Path:
25
+ return session_dir_ / "meta.yaml"
26
+
27
+
28
+ def tags_path(session_dir_: Path) -> Path:
29
+ return session_dir_ / "tags.jsonl"
File without changes