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 +4 -0
- thirdeye/__main__.py +4 -0
- thirdeye/_version.py +24 -0
- thirdeye/cli.py +29 -0
- thirdeye/codec.py +19 -0
- thirdeye/commands/__init__.py +0 -0
- thirdeye/commands/add.py +43 -0
- thirdeye/commands/ingest.py +33 -0
- thirdeye/commands/reads.py +175 -0
- thirdeye/commands/tags.py +112 -0
- thirdeye/config.py +33 -0
- thirdeye/ids.py +29 -0
- thirdeye/index.py +86 -0
- thirdeye/meta.py +47 -0
- thirdeye/paths.py +29 -0
- thirdeye/platforms/__init__.py +0 -0
- thirdeye/platforms/base.py +14 -0
- thirdeye/platforms/claude/__init__.py +0 -0
- thirdeye/platforms/claude/constants.py +20 -0
- thirdeye/platforms/claude/hooks.py +122 -0
- thirdeye/platforms/claude/install.py +77 -0
- thirdeye/platforms/codex/__init__.py +0 -0
- thirdeye/platforms/codex/constants.py +9 -0
- thirdeye/platforms/codex/hooks.py +72 -0
- thirdeye/platforms/codex/install.py +101 -0
- thirdeye/platforms/gemini/__init__.py +0 -0
- thirdeye/platforms/gemini/constants.py +30 -0
- thirdeye/platforms/gemini/hooks.py +174 -0
- thirdeye/platforms/gemini/install.py +101 -0
- thirdeye/reader.py +58 -0
- thirdeye/render.py +61 -0
- thirdeye/search.py +76 -0
- thirdeye/store.py +176 -0
- thirdeye/tags.py +140 -0
- thirdeye/timeparse.py +61 -0
- thirdeye/writer.py +113 -0
- thrdi-0.0.1.dist-info/METADATA +68 -0
- thrdi-0.0.1.dist-info/RECORD +42 -0
- thrdi-0.0.1.dist-info/WHEEL +5 -0
- thrdi-0.0.1.dist-info/entry_points.txt +22 -0
- thrdi-0.0.1.dist-info/licenses/LICENSE +21 -0
- thrdi-0.0.1.dist-info/top_level.txt +1 -0
thirdeye/__init__.py
ADDED
thirdeye/__main__.py
ADDED
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
|
thirdeye/commands/add.py
ADDED
|
@@ -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
|