prompt_objects 0.3.0 → 0.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 676489da9fefa02b4d187f99137469c6cc0011d99e7e43b72574c0d57b19c8a7
4
- data.tar.gz: acd7c61e837ac00485cb8286cf95caa3d2103c82d07aa5fef7ce49185a3a0f6a
3
+ metadata.gz: 0a1ec32b66d34c2fe11c964759513487335794f9a8c336bd32f7ae9aeed5380c
4
+ data.tar.gz: 9a6650251228ab4dae25f16120e8ae8c1992e4458faf0c858894b6e4848ae80b
5
5
  SHA512:
6
- metadata.gz: dda01aa39c36088e3433894b004994b4a24b735bd6b59bb6ae510943be0f6f83931d1e45fa34051b4f49a7243b606b7ac71b8790f8e5701f9652373dff29d756
7
- data.tar.gz: e02fb7e206ced06445391d81012a61d790277f4fc0f46343e3e0391f46da58aafd3a047abd13147e0c2e8fe150284b5333759f27ab1037ccb6a7e8020d18316a
6
+ metadata.gz: 867fc4cb49114ace4af4682feff5a062eb414f0ce84e4be4e48632609d68cec2ac201be2cb43fb995ac5e9cb4ec0480399aa6eaad7580da81fd5451905b0ddf5
7
+ data.tar.gz: 4b3d3c3d96d45a2573829345fd1f822fa3f8918c9d4088cccc46d82ba4fa797bb7c9838e74d443017069f7cd9b9d343d3bd92219930b12098f036d038e05ef83
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to PromptObjects are documented in this file.
4
4
 
5
+ ## [0.3.1] - 2025-02-08
6
+
7
+ ### Added
8
+
9
+ - **Thread Explorer** — Standalone HTML visualizer for exploring conversation thread exports. Three views: sequence diagram (swim lanes showing PO communication), timeline (flat chronological event list), and detail panel (full conversation with tool calls). Includes token cost bar, search, per-PO filtering, breadcrumb navigation, and structural event highlighting.
10
+ - **`explore` CLI command** — Open Thread Explorer from the command line. `prompt_objects explore <env>` lists root threads and opens the most recent; `--session ID` targets a specific thread. Data is embedded directly so it opens ready to go.
11
+
12
+ ### Fixed
13
+
14
+ - Delegation messages now correctly show the calling PO's name instead of "human". Previously `context.current_capability` resolved to the target PO (matching its own name), causing a fallback to "human". Now uses `context.calling_po`.
15
+ - CLI integration tests skip gracefully in CI when no LLM API key is available, instead of failing.
16
+
5
17
  ## [0.3.0] - 2025-02-05
6
18
 
7
19
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- prompt_objects (0.3.0)
4
+ prompt_objects (0.3.1)
5
5
  anthropic (~> 1.0)
6
6
  async-websocket (~> 0.28)
7
7
  falcon (~> 0.50)
data/exe/prompt_objects CHANGED
@@ -285,6 +285,7 @@ def print_main_help
285
285
  serve <env> Run environment as a web server
286
286
  message <env> <po> "text" Send a message to a PO and print the response
287
287
  events <env> [--session ID] Show recent events from the message bus
288
+ explore [env] [--session ID] Open Thread Explorer to visualize conversations
288
289
  repl [name] [objects_dir] Start interactive REPL with a prompt object
289
290
  help Show this help message
290
291
 
@@ -297,6 +298,7 @@ def print_main_help
297
298
  prompt_objects serve my-env --open # Start and open browser
298
299
  prompt_objects message my-env solver "Hello" # Send a message
299
300
  prompt_objects message my-env solver "Hello" --json # JSON output
301
+ prompt_objects explore my-env # Visualize conversations
300
302
  HELP
301
303
  end
302
304
 
@@ -920,6 +922,163 @@ def print_events(events)
920
922
  end
921
923
  end
922
924
 
925
+ # === Explore Command ===
926
+
927
+ def run_explore(args)
928
+ options = { session_id: nil }
929
+ positional = []
930
+ skip_next = false
931
+
932
+ args.each_with_index do |arg, i|
933
+ if skip_next
934
+ skip_next = false
935
+ next
936
+ end
937
+
938
+ case arg
939
+ when "--session"
940
+ options[:session_id] = args[i + 1]
941
+ skip_next = true
942
+ when "--help", "-h"
943
+ print_explore_help
944
+ exit 0
945
+ else
946
+ positional << arg
947
+ end
948
+ end
949
+
950
+ explorer_html = File.expand_path("../tools/thread-explorer.html", __dir__)
951
+
952
+ # No args: just open the empty visualizer
953
+ unless positional[0]
954
+ open_in_browser("file://#{explorer_html}")
955
+ puts "Opened Thread Explorer in browser"
956
+ exit 0
957
+ end
958
+
959
+ env_path = resolve_environment(positional[0])
960
+ unless env_path
961
+ $stderr.puts "Error: environment '#{positional[0]}' not found"
962
+ exit 1
963
+ end
964
+
965
+ db_path = File.join(env_path, "sessions.db")
966
+ unless File.exist?(db_path)
967
+ $stderr.puts "No session data found (sessions.db not found)"
968
+ $stderr.puts "Opening empty Thread Explorer..."
969
+ open_in_browser("file://#{explorer_html}")
970
+ exit 0
971
+ end
972
+
973
+ store = PromptObjects::Session::Store.new(db_path)
974
+
975
+ if options[:session_id]
976
+ # Export a specific thread
977
+ export_and_open(store, options[:session_id], explorer_html)
978
+ else
979
+ # List sessions and let user choose, or export the most recent root thread
980
+ # Query root sessions directly (list_sessions requires po_name)
981
+ rows = store.instance_variable_get(:@db).execute(<<~SQL)
982
+ SELECT * FROM sessions
983
+ WHERE thread_type IS NULL OR thread_type = 'root'
984
+ ORDER BY created_at ASC
985
+ SQL
986
+ root_sessions = rows.map { |r| { id: r["id"], po_name: r["po_name"], name: r["name"], thread_type: r["thread_type"], created_at: r["created_at"] } }
987
+
988
+ if root_sessions.empty?
989
+ $stderr.puts "No root sessions found"
990
+ open_in_browser("file://#{explorer_html}")
991
+ exit 0
992
+ end
993
+
994
+ # Show available sessions
995
+ puts "Root threads:"
996
+ root_sessions.last(10).each_with_index do |s, i|
997
+ name = s[:name] || "(unnamed)"
998
+ po = s[:po_name] || "?"
999
+ time = s[:created_at]
1000
+ time_str = time.is_a?(Time) ? time.strftime("%Y-%m-%d %H:%M") : time.to_s[0, 16]
1001
+ puts " #{i + 1}. [#{po}] #{name} (#{time_str}) #{s[:id]}"
1002
+ end
1003
+
1004
+ puts
1005
+ puts "Opening most recent thread..."
1006
+ export_and_open(store, root_sessions.last[:id], explorer_html)
1007
+ end
1008
+
1009
+ store.close
1010
+ end
1011
+
1012
+ def export_and_open(store, session_id, explorer_html)
1013
+ data = store.export_thread_tree_json(session_id)
1014
+ unless data
1015
+ $stderr.puts "Error: session '#{session_id}' not found"
1016
+ exit 1
1017
+ end
1018
+
1019
+ json_str = JSON.generate(data)
1020
+
1021
+ # Read the explorer HTML and inject the data as auto-load
1022
+ html_content = File.read(explorer_html)
1023
+ inject_script = <<~JS
1024
+ <script>
1025
+ // Auto-load embedded data
1026
+ window.addEventListener('DOMContentLoaded', function() {
1027
+ var data = #{json_str};
1028
+ if (typeof loadData === 'function') loadData(data);
1029
+ });
1030
+ </script>
1031
+ JS
1032
+
1033
+ # Insert before </body>
1034
+ html_content = html_content.sub("</body>", "#{inject_script}</body>")
1035
+
1036
+ # Write to temp file
1037
+ require "tempfile"
1038
+ tmp = Tempfile.new(["thread-explorer-", ".html"])
1039
+ tmp.write(html_content)
1040
+ tmp.close
1041
+
1042
+ open_in_browser("file://#{tmp.path}")
1043
+ puts "Opened Thread Explorer for session #{session_id}"
1044
+
1045
+ # Keep temp file around briefly so the browser can load it
1046
+ sleep 2
1047
+ end
1048
+
1049
+ def open_in_browser(url)
1050
+ case RUBY_PLATFORM
1051
+ when /darwin/
1052
+ system("open", url)
1053
+ when /linux/
1054
+ system("xdg-open", url)
1055
+ when /mswin|mingw|cygwin/
1056
+ system("start", url)
1057
+ else
1058
+ $stderr.puts "Open this URL in your browser: #{url}"
1059
+ end
1060
+ end
1061
+
1062
+ def print_explore_help
1063
+ puts <<~HELP
1064
+ Usage: prompt_objects explore [env] [options]
1065
+
1066
+ Open the Thread Explorer to visualize conversation threads.
1067
+
1068
+ Arguments:
1069
+ env Environment name or path (optional)
1070
+
1071
+ Options:
1072
+ --session ID Open a specific thread by session ID
1073
+ --help, -h Show this help message
1074
+
1075
+ Examples:
1076
+ prompt_objects explore # Open empty explorer
1077
+ prompt_objects explore my-env # Open most recent thread
1078
+ prompt_objects explore my-env --session abc123 # Open specific thread
1079
+ HELP
1080
+ end
1081
+
923
1082
  # === Main Entry Point ===
924
1083
 
925
1084
  def run_env(args)
@@ -947,6 +1106,8 @@ def main
947
1106
  run_message(args)
948
1107
  when "events"
949
1108
  run_events(args)
1109
+ when "explore"
1110
+ run_explore(args)
950
1111
  when "help", "--help", "-h"
951
1112
  print_main_help
952
1113
  else
@@ -63,7 +63,7 @@ module PromptObjects
63
63
  content = normalize_message(message)
64
64
 
65
65
  # Track who sent this message - another PO or a human?
66
- sender = context.current_capability
66
+ sender = context.calling_po
67
67
  from = (sender && sender != name) ? sender : "human"
68
68
 
69
69
  user_msg = { role: :user, content: content, from: from }
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "prompt_objects"
5
- spec.version = "0.3.0"
5
+ spec.version = "0.3.1"
6
6
  spec.authors = ["Scott Werner"]
7
7
  spec.email = ["scott@sublayer.com"]
8
8
 
@@ -0,0 +1,1043 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Thread Explorer | PromptObjects</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
10
+ <style>
11
+ /* ======== THEME ======== */
12
+ :root {
13
+ --bg-deep: #0a0e0a;
14
+ --bg: #0d120d;
15
+ --bg-raised: #131a13;
16
+ --bg-surface: #182018;
17
+ --border: #1e2b1e;
18
+ --border-bright: #2a3d2a;
19
+ --phosphor: #33ff66;
20
+ --phosphor-dim: #1a8033;
21
+ --phosphor-faint: #0d4020;
22
+ --phosphor-glow: rgba(51,255,102,0.15);
23
+ --amber: #ffaa33;
24
+ --amber-dim: #805515;
25
+ --amber-glow: rgba(255,170,51,0.12);
26
+ --cyan: #33ddff;
27
+ --cyan-dim: #1a7088;
28
+ --magenta: #dd55ff;
29
+ --magenta-dim: #6b2a80;
30
+ --red: #ff4455;
31
+ --red-dim: #661a22;
32
+ --pink: #ff6699;
33
+ --teal: #33ffcc;
34
+ --yellow: #ffdd44;
35
+ --text: #b8ccb8;
36
+ --text-dim: #667766;
37
+ --text-bright: #ddeedd;
38
+ --mono: 'IBM Plex Mono','Menlo',monospace;
39
+ }
40
+ * { margin:0; padding:0; box-sizing:border-box; }
41
+ html,body { height:100%; background:var(--bg-deep); color:var(--text); font-family:var(--mono); font-size:13px; line-height:1.6; overflow:hidden; }
42
+ body::after { content:''; position:fixed; inset:0; background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px); pointer-events:none; z-index:9999; }
43
+ ::selection { background:var(--phosphor-dim); color:var(--text-bright); }
44
+ ::-webkit-scrollbar { width:5px; height:5px; }
45
+ ::-webkit-scrollbar-track { background:var(--bg); }
46
+ ::-webkit-scrollbar-thumb { background:var(--border-bright); border-radius:3px; }
47
+ ::-webkit-scrollbar-thumb:hover { background:var(--phosphor-dim); }
48
+ button { font-family:var(--mono); cursor:pointer; }
49
+
50
+ /* ======== DROP ZONE ======== */
51
+ #drop-zone { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:100; background:var(--bg-deep); transition:opacity 0.4s; }
52
+ #drop-zone.hidden { opacity:0; pointer-events:none; }
53
+ #drop-zone .logo { font-size:36px; font-weight:700; color:var(--phosphor); text-shadow:0 0 30px var(--phosphor-glow); letter-spacing:-1px; margin-bottom:6px; animation:flicker 4s ease-in-out infinite; }
54
+ #drop-zone .subtitle { font-size:11px; color:var(--text-dim); margin-bottom:40px; letter-spacing:3px; text-transform:uppercase; }
55
+ .drop-area { width:480px; height:240px; border:2px dashed var(--border-bright); border-radius:4px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; transition:all 0.2s; cursor:pointer; background:var(--bg); }
56
+ .drop-area:hover,.drop-area.dragover { border-color:var(--phosphor-dim); background:rgba(51,255,102,0.02); }
57
+ .drop-area .icon { font-size:40px; color:var(--phosphor-dim); transition:color 0.2s; }
58
+ .drop-area:hover .icon,.drop-area.dragover .icon { color:var(--phosphor); }
59
+ .drop-area p { color:var(--text-dim); font-size:13px; }
60
+ .drop-area p span { color:var(--phosphor); text-decoration:underline; text-underline-offset:3px; }
61
+ .drop-area .hint { font-size:11px; opacity:0.5; }
62
+ #file-input { display:none; }
63
+ .sample-link { margin-top:16px; font-size:11px; color:var(--text-dim); cursor:pointer; text-decoration:underline; text-underline-offset:3px; opacity:0.6; transition:opacity 0.2s; }
64
+ .sample-link:hover { opacity:1; color:var(--phosphor-dim); }
65
+
66
+ /* ======== APP LAYOUT ======== */
67
+ #app { display:none; height:100vh; grid-template-columns:1fr; grid-template-rows:auto auto 1fr; }
68
+ #app.visible { display:grid; }
69
+
70
+ /* Header */
71
+ #header { display:flex; align-items:center; gap:16px; padding:0 16px; height:40px; background:var(--bg); border-bottom:1px solid var(--border); }
72
+ .brand-name { font-weight:700; font-size:14px; color:var(--phosphor); text-shadow:0 0 20px var(--phosphor-glow); white-space:nowrap; }
73
+ .brand-sep { color:var(--border-bright); }
74
+ .thread-name { color:var(--text); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
75
+ .header-stats { display:flex; gap:14px; font-size:11px; color:var(--text-dim); margin-left:auto; white-space:nowrap; }
76
+ .header-stats .v { color:var(--text); font-weight:500; }
77
+ .btn-open { background:none; border:1px solid var(--border-bright); color:var(--text-dim); font-size:10px; padding:3px 10px; border-radius:3px; transition:all 0.15s; margin-left:8px; }
78
+ .btn-open:hover { border-color:var(--phosphor-dim); color:var(--phosphor); }
79
+
80
+ /* Token cost bar */
81
+ #cost-bar { display:flex; height:20px; background:var(--bg); border-bottom:1px solid var(--border); padding:0 16px; align-items:center; gap:8px; }
82
+ #cost-bar .label { font-size:10px; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.5px; white-space:nowrap; }
83
+ #cost-bar .bar { flex:1; height:8px; background:var(--bg-deep); border-radius:2px; overflow:hidden; display:flex; }
84
+ #cost-bar .bar-seg { height:100%; transition:opacity 0.15s; cursor:pointer; position:relative; min-width:2px; }
85
+ #cost-bar .bar-seg:hover { opacity:0.8; }
86
+ #cost-bar .bar-legend { display:flex; gap:10px; font-size:10px; color:var(--text-dim); }
87
+ #cost-bar .bar-legend .dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:3px; vertical-align:middle; }
88
+
89
+ /* Toolbar */
90
+ #toolbar { display:flex; align-items:center; height:32px; background:var(--bg); border-bottom:1px solid var(--border); padding:0 16px; gap:8px; }
91
+ .tab { background:none; border:none; color:var(--text-dim); font-size:11px; padding:4px 12px; border-radius:3px; transition:all 0.15s; text-transform:uppercase; letter-spacing:0.5px; font-weight:500; }
92
+ .tab:hover { color:var(--text); background:var(--bg-raised); }
93
+ .tab.active { color:var(--phosphor); background:var(--phosphor-faint); }
94
+ .toolbar-sep { width:1px; height:16px; background:var(--border); }
95
+ #search { background:var(--bg-deep); border:1px solid var(--border); color:var(--text); font-family:var(--mono); font-size:11px; padding:3px 8px; border-radius:3px; width:200px; outline:none; transition:border-color 0.15s; }
96
+ #search:focus { border-color:var(--phosphor-dim); }
97
+ #search::placeholder { color:var(--text-dim); }
98
+ .filter-btns { display:flex; gap:4px; margin-left:auto; }
99
+ .filter-btn { background:none; border:1px solid var(--border); color:var(--text-dim); font-size:10px; padding:2px 8px; border-radius:3px; transition:all 0.15s; }
100
+ .filter-btn:hover { border-color:var(--border-bright); color:var(--text); }
101
+ .filter-btn.active { border-color:var(--phosphor-dim); color:var(--phosphor); background:var(--phosphor-faint); }
102
+
103
+ /* ======== MAIN CONTENT ======== */
104
+ #main { overflow:hidden; display:grid; }
105
+
106
+ /* Split view */
107
+ #main.split { grid-template-columns:1fr 1fr; }
108
+ #main.full { grid-template-columns:1fr; }
109
+
110
+ /* ======== SEQUENCE VIEW ======== */
111
+ #seq-view { overflow:auto; background:var(--bg-deep); }
112
+ .seq-actors { display:flex; position:sticky; top:0; z-index:5; background:var(--bg); border-bottom:1px solid var(--border); }
113
+ .seq-actor { flex:1; min-width:100px; text-align:center; padding:6px 4px; font-size:11px; font-weight:600; color:var(--text-dim); border-right:1px solid var(--border); cursor:pointer; transition:all 0.15s; position:relative; }
114
+ .seq-actor:last-child { border-right:none; }
115
+ .seq-actor:hover { background:var(--bg-raised); }
116
+ .seq-actor .actor-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:4px; vertical-align:middle; }
117
+ .seq-actor.filtered { background:var(--phosphor-faint); color:var(--phosphor); }
118
+ .seq-events { position:relative; }
119
+
120
+ /* Lifelines */
121
+ .seq-lifelines { position:absolute; inset:0; display:flex; pointer-events:none; z-index:0; }
122
+ .seq-lifeline { flex:1; min-width:100px; border-right:1px dashed var(--border); position:relative; }
123
+ .seq-lifeline:last-child { border-right:none; }
124
+
125
+ /* Event rows */
126
+ .seq-row { position:relative; min-height:32px; display:flex; align-items:center; border-bottom:1px solid rgba(30,43,30,0.3); z-index:1; cursor:pointer; transition:background 0.1s; }
127
+ .seq-row:hover { background:rgba(51,255,102,0.02); }
128
+ .seq-row.active { background:rgba(51,255,102,0.05); }
129
+ .seq-row.structural { background:rgba(255,68,85,0.03); }
130
+ .seq-row .row-content { position:absolute; left:0; right:0; display:flex; align-items:center; padding:0; height:100%; }
131
+
132
+ /* Arrow rendering */
133
+ .seq-arrow-container { position:absolute; top:50%; height:0; display:flex; align-items:center; }
134
+ .seq-arrow-line { height:2px; flex:1; position:relative; }
135
+ .seq-arrow-head { width:0; height:0; border-top:5px solid transparent; border-bottom:5px solid transparent; flex-shrink:0; }
136
+ .seq-arrow-head.right { border-left:8px solid; }
137
+ .seq-arrow-head.left { border-right:8px solid; }
138
+ .seq-arrow-label { position:absolute; top:-16px; font-size:10px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; padding:0 4px; }
139
+ .seq-arrow-label.above-right { left:4px; }
140
+ .seq-arrow-label.above-left { right:4px; text-align:right; }
141
+
142
+ /* Event type styling on arrows */
143
+ .seq-row.type-delegation .seq-arrow-line { height:3px; }
144
+ .seq-row.type-delegation_return .seq-arrow-line { height:1px; opacity:0.5; }
145
+ .seq-row.type-tool_call .seq-arrow-line { height:1px; opacity:0.6; }
146
+ .seq-row.type-tool_result .seq-arrow-line { height:1px; opacity:0.4; }
147
+
148
+ /* Structural event badge */
149
+ .seq-structural-badge { position:absolute; font-size:9px; padding:1px 6px; border-radius:2px; background:var(--red-dim); color:var(--red); font-weight:600; text-transform:uppercase; letter-spacing:0.5px; white-space:nowrap; top:50%; transform:translateY(-50%); }
150
+
151
+ /* Self-arrow (tool on same actor) */
152
+ .seq-self-arrow { position:absolute; width:30px; height:20px; border:1px solid; border-left:none; border-radius:0 6px 6px 0; top:50%; transform:translateY(-50%); }
153
+
154
+ /* Collapsed group */
155
+ .seq-group { position:relative; border-bottom:1px solid rgba(30,43,30,0.3); cursor:pointer; transition:background 0.1s; }
156
+ .seq-group:hover { background:rgba(51,255,102,0.02); }
157
+ .seq-group-header { display:flex; align-items:center; padding:4px 8px; min-height:28px; font-size:11px; color:var(--text-dim); gap:6px; }
158
+ .seq-group-header .chevron { font-size:9px; transition:transform 0.15s; width:10px; color:var(--text-dim); }
159
+ .seq-group-header.expanded .chevron { transform:rotate(90deg); }
160
+ .seq-group-header .group-label { opacity:0.7; }
161
+ .seq-group-body { display:none; }
162
+ .seq-group-body.expanded { display:block; }
163
+
164
+ /* ======== TIMELINE VIEW ======== */
165
+ #timeline-view { overflow:auto; background:var(--bg-deep); }
166
+ .tl-event { display:flex; align-items:flex-start; padding:6px 16px; gap:10px; border-bottom:1px solid rgba(30,43,30,0.3); cursor:pointer; transition:background 0.1s; min-height:32px; }
167
+ .tl-event:hover { background:rgba(51,255,102,0.02); }
168
+ .tl-event.active { background:rgba(51,255,102,0.05); }
169
+ .tl-event.structural { border-left:2px solid var(--red); }
170
+ .tl-idx { font-size:10px; color:var(--text-dim); width:28px; text-align:right; flex-shrink:0; padding-top:2px; opacity:0.5; }
171
+ .tl-time { font-size:10px; color:var(--text-dim); width:60px; flex-shrink:0; padding-top:2px; }
172
+ .tl-actor-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; margin-top:5px; }
173
+ .tl-body { flex:1; min-width:0; }
174
+ .tl-header-line { display:flex; align-items:center; gap:6px; font-size:12px; }
175
+ .tl-from { font-weight:600; }
176
+ .tl-arrow { color:var(--text-dim); font-size:10px; }
177
+ .tl-to { font-weight:500; }
178
+ .tl-type-badge { font-size:9px; padding:1px 5px; border-radius:2px; text-transform:uppercase; letter-spacing:0.3px; font-weight:600; margin-left:4px; }
179
+ .tl-type-badge.delegation { background:rgba(255,170,51,0.15); color:var(--amber); }
180
+ .tl-type-badge.structural { background:rgba(255,68,85,0.15); color:var(--red); }
181
+ .tl-type-badge.tool { background:rgba(102,119,102,0.15); color:var(--text-dim); }
182
+ .tl-preview { font-size:11px; color:var(--text-dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:2px; max-width:600px; }
183
+ .tl-usage { font-size:10px; color:var(--text-dim); opacity:0.5; margin-top:1px; }
184
+ .tl-depth-indent { display:inline-block; }
185
+
186
+ /* ======== DETAIL VIEW ======== */
187
+ #detail-view { overflow:auto; background:var(--bg-deep); border-left:1px solid var(--border); }
188
+ .detail-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text-dim); gap:6px; }
189
+ .detail-empty .icon { font-size:28px; opacity:0.3; }
190
+ .detail-header { position:sticky; top:0; z-index:5; background:var(--bg); border-bottom:1px solid var(--border); padding:10px 16px; display:flex; align-items:center; justify-content:space-between; }
191
+ .detail-header .po-name { font-weight:700; font-size:15px; color:var(--text-bright); }
192
+ .detail-header .type-badge { font-size:9px; text-transform:uppercase; letter-spacing:0.5px; padding:2px 6px; border-radius:2px; font-weight:600; margin-left:8px; }
193
+ .badge-root { background:var(--phosphor-faint); color:var(--phosphor); }
194
+ .badge-delegation { background:rgba(255,170,51,0.1); color:var(--amber); }
195
+ .detail-header .parent-info { font-size:10px; color:var(--text-dim); margin-left:8px; }
196
+ .detail-header .parent-info em { color:var(--amber); font-style:normal; }
197
+ .detail-header .stats { font-size:10px; color:var(--text-dim); display:flex; gap:12px; }
198
+ .detail-header .stats .v { color:var(--text); }
199
+
200
+ /* Breadcrumbs */
201
+ .breadcrumbs { padding:6px 16px; background:var(--bg); border-bottom:1px solid var(--border); font-size:11px; display:flex; align-items:center; gap:4px; }
202
+ .breadcrumb { color:var(--text-dim); cursor:pointer; transition:color 0.15s; }
203
+ .breadcrumb:hover { color:var(--phosphor); }
204
+ .breadcrumb.current { color:var(--text-bright); cursor:default; }
205
+ .breadcrumb-sep { color:var(--border-bright); }
206
+
207
+ /* Messages */
208
+ .messages { padding:12px 16px 60px; }
209
+ .msg { margin-bottom:2px; animation:fadeIn 0.12s ease; }
210
+ .msg-head { display:flex; align-items:center; gap:6px; padding-top:10px; margin-bottom:4px; }
211
+ .msg-sender { font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:0.3px; }
212
+ .msg-sender.human { color:var(--cyan); }
213
+ .msg-sender.assistant { color:var(--phosphor); }
214
+ .msg-sender.delegator { color:var(--amber); }
215
+ .msg-ts { font-size:10px; color:var(--text-dim); opacity:0.4; }
216
+ .msg-tokens { font-size:10px; color:var(--text-dim); margin-left:auto; }
217
+ .msg-body { padding:8px 12px; border-radius:3px; font-size:12px; line-height:1.65; white-space:pre-wrap; word-break:break-word; }
218
+ .msg.user .msg-body { background:rgba(51,221,255,0.04); border-left:2px solid var(--cyan-dim); }
219
+ .msg.user.from-po .msg-body { background:rgba(255,170,51,0.04); border-left:2px solid var(--amber-dim); }
220
+ .msg.assistant .msg-body { background:rgba(51,255,102,0.03); border-left:2px solid var(--phosphor-faint); }
221
+ .msg.highlighted .msg-body { box-shadow:inset 0 0 0 1px var(--phosphor-dim); }
222
+
223
+ /* Tool calls in detail */
224
+ .tc { margin:3px 0; border:1px solid var(--border); border-radius:3px; overflow:hidden; }
225
+ .tc-head { display:flex; align-items:center; gap:6px; padding:6px 10px; background:var(--bg-raised); cursor:pointer; font-size:11px; user-select:none; transition:background 0.1s; }
226
+ .tc-head:hover { background:var(--bg-surface); }
227
+ .tc-head .chv { color:var(--text-dim); font-size:9px; width:10px; transition:transform 0.15s; }
228
+ .tc-head.open .chv { transform:rotate(90deg); }
229
+ .tc-head .tc-name { font-weight:600; color:var(--phosphor-dim); }
230
+ .tc-head .tc-label { color:var(--text-dim); font-size:10px; }
231
+ .tc-body { display:none; border-top:1px solid var(--border); }
232
+ .tc-body.open { display:block; }
233
+ .tc-section { padding:8px 10px; }
234
+ .tc-section + .tc-section { border-top:1px solid var(--border); }
235
+ .tc-section-label { font-size:9px; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-dim); margin-bottom:4px; }
236
+ .tc-section pre { font-family:var(--mono); font-size:11px; line-height:1.5; color:var(--text); white-space:pre-wrap; word-break:break-all; max-height:250px; overflow-y:auto; }
237
+ .tc-result { max-height:150px; overflow-y:auto; font-size:11px; color:var(--text-dim); white-space:pre-wrap; word-break:break-all; line-height:1.5; }
238
+
239
+ /* Delegation marker in detail */
240
+ .deleg-marker { display:flex; align-items:center; gap:6px; padding:6px 10px; margin:4px 0; border:1px dashed var(--amber-dim); border-radius:3px; background:rgba(255,170,51,0.03); font-size:11px; color:var(--amber); cursor:pointer; transition:all 0.15s; }
241
+ .deleg-marker:hover { background:rgba(255,170,51,0.06); border-color:var(--amber); }
242
+ .deleg-marker .dm-info { opacity:0.5; margin-left:auto; font-size:10px; }
243
+
244
+ /* ======== ANIMATIONS ======== */
245
+ @keyframes flicker { 0%,100%{opacity:1} 92%{opacity:1} 93%{opacity:0.8} 94%{opacity:1} 96%{opacity:0.9} 97%{opacity:1} }
246
+ @keyframes fadeIn { from{opacity:0;transform:translateY(3px)} to{opacity:1;transform:translateY(0)} }
247
+ </style>
248
+ </head>
249
+ <body>
250
+
251
+ <div id="drop-zone">
252
+ <div class="logo">Thread Explorer</div>
253
+ <div class="subtitle">PromptObjects</div>
254
+ <div class="drop-area" id="drop-area">
255
+ <div class="icon">{...}</div>
256
+ <p>Drop a thread export <span>JSON</span> here</p>
257
+ <p class="hint">or click to browse</p>
258
+ </div>
259
+ <input type="file" id="file-input" accept=".json,application/json">
260
+ <div class="sample-link" id="load-sample">load sample data</div>
261
+ </div>
262
+
263
+ <div id="app">
264
+ <div id="header">
265
+ <span class="brand-name">Thread Explorer</span>
266
+ <span class="brand-sep">/</span>
267
+ <span class="thread-name" id="h-name">---</span>
268
+ <div class="header-stats" id="h-stats"></div>
269
+ <button class="btn-open" id="btn-open">Open</button>
270
+ </div>
271
+ <div id="cost-bar">
272
+ <span class="label">Tokens</span>
273
+ <div class="bar" id="cost-bar-inner"></div>
274
+ <div class="bar-legend" id="cost-legend"></div>
275
+ </div>
276
+ <div id="main" class="split">
277
+ <div style="display:flex;flex-direction:column;overflow:hidden;">
278
+ <div id="toolbar">
279
+ <button class="tab active" data-view="sequence">Sequence</button>
280
+ <button class="tab" data-view="timeline">Timeline</button>
281
+ <div class="toolbar-sep"></div>
282
+ <input id="search" type="text" placeholder="Search messages...">
283
+ <div class="filter-btns" id="filter-btns"></div>
284
+ </div>
285
+ <div id="seq-view" style="flex:1;overflow:auto;"></div>
286
+ <div id="timeline-view" style="flex:1;overflow:auto;display:none;"></div>
287
+ </div>
288
+ <div id="detail-view">
289
+ <div class="detail-empty"><div class="icon">&larr;</div><div>Select an event</div></div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+
294
+ <script>
295
+ // ========================================
296
+ // THREAD EXPLORER
297
+ // ========================================
298
+
299
+ // --- State ---
300
+ let treeData = null;
301
+ let events = []; // flattened event sequence
302
+ let actors = []; // ordered actor names
303
+ let poNames = new Set();
304
+ let actorColors = {};
305
+ let activeView = 'sequence';
306
+ let selectedEventIdx = null;
307
+ let selectedSessionId = null;
308
+ let searchQuery = '';
309
+ let activeFilters = new Set(); // PO name filters
310
+ let allNodes = {}; // id -> node for quick lookup
311
+
312
+ const COLORS = ['#33ff66','#ffaa33','#33ddff','#dd55ff','#ff6699','#33ffcc','#ffdd44','#88aaff','#ff8844','#99ff33'];
313
+ const STRUCTURAL_TOOLS = new Set(['create_capability','add_capability','request_capability','remove_capability','modify_prompt','create_po','delete_primitive','ask_human']);
314
+
315
+ // --- DOM refs ---
316
+ const dropZone = document.getElementById('drop-zone');
317
+ const dropArea = document.getElementById('drop-area');
318
+ const fileInput = document.getElementById('file-input');
319
+ const app = document.getElementById('app');
320
+
321
+ // --- File loading ---
322
+ dropArea.addEventListener('click', () => fileInput.click());
323
+ fileInput.addEventListener('change', e => { if(e.target.files[0]) loadFile(e.target.files[0]); });
324
+ dropArea.addEventListener('dragover', e => { e.preventDefault(); dropArea.classList.add('dragover'); });
325
+ dropArea.addEventListener('dragleave', () => dropArea.classList.remove('dragover'));
326
+ dropArea.addEventListener('drop', e => { e.preventDefault(); dropArea.classList.remove('dragover'); if(e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]); });
327
+ document.getElementById('btn-open').addEventListener('click', () => { const i=document.createElement('input'); i.type='file'; i.accept='.json'; i.onchange=e=>{if(e.target.files[0])loadFile(e.target.files[0]);}; i.click(); });
328
+ document.getElementById('load-sample').addEventListener('click', () => loadData(generateSampleData()));
329
+
330
+ function loadFile(file) {
331
+ const r = new FileReader();
332
+ r.onload = e => { try { loadData(JSON.parse(e.target.result)); } catch(err) { alert('Parse error: '+err.message); } };
333
+ r.readAsText(file);
334
+ }
335
+
336
+ function loadData(data) {
337
+ treeData = data;
338
+ allNodes = {};
339
+ indexNodes(data);
340
+ poNames = extractPONames(data);
341
+ events = extractEvents(data);
342
+ actors = extractActors();
343
+ assignColors();
344
+ selectedEventIdx = null;
345
+ selectedSessionId = null;
346
+ activeFilters = new Set();
347
+ searchQuery = '';
348
+ document.getElementById('search').value = '';
349
+
350
+ dropZone.classList.add('hidden');
351
+ app.classList.add('visible');
352
+
353
+ renderAll();
354
+ // Auto-select first event
355
+ if (events.length > 0) selectEvent(0);
356
+ }
357
+
358
+ function indexNodes(node, parent) {
359
+ const id = node.session?.id || 'n-'+Object.keys(allNodes).length;
360
+ allNodes[id] = { ...node, _parent: parent || null };
361
+ (node.children||[]).forEach(c => indexNodes(c, node));
362
+ }
363
+
364
+ function extractPONames(node) {
365
+ const s = new Set();
366
+ s.add(node.session?.po_name);
367
+ (node.children||[]).forEach(c => extractPONames(c).forEach(n => s.add(n)));
368
+ return s;
369
+ }
370
+
371
+ function extractActors() {
372
+ const ordered = ['human'];
373
+ const seen = new Set(['human']);
374
+ events.forEach(e => {
375
+ [e.from, e.to].forEach(a => {
376
+ if (a && !seen.has(a) && (poNames.has(a) || a === 'human')) { seen.add(a); ordered.push(a); }
377
+ });
378
+ });
379
+ // Add "tools" pseudo-actor if any tool calls exist
380
+ if (events.some(e => e.type === 'tool_call' || e.type === 'tool_result')) {
381
+ ordered.push('[tools]');
382
+ }
383
+ return ordered;
384
+ }
385
+
386
+ function assignColors() {
387
+ actorColors = {};
388
+ let ci = 0;
389
+ actors.forEach(a => {
390
+ if (a === 'human') actorColors[a] = '#33ddff';
391
+ else if (a === '[tools]') actorColors[a] = '#667766';
392
+ else { actorColors[a] = COLORS[ci % COLORS.length]; ci++; }
393
+ });
394
+ }
395
+
396
+ // --- Event extraction ---
397
+ function extractEvents(node, depth) {
398
+ depth = depth || 0;
399
+ const evts = [];
400
+ const session = node.session || {};
401
+ const poName = session.po_name || 'unknown';
402
+ const messages = node.messages || [];
403
+
404
+ // Delegation children lookup
405
+ const delegKids = {};
406
+ (node.children||[]).forEach(c => {
407
+ const cp = c.session?.po_name;
408
+ if (c.session?.thread_type === 'delegation' && cp) {
409
+ if (!delegKids[cp]) delegKids[cp] = [];
410
+ delegKids[cp].push(c);
411
+ }
412
+ });
413
+ const delegCallIds = new Set();
414
+
415
+ for (const msg of messages) {
416
+ if (msg.role === 'user') {
417
+ evts.push({
418
+ type: 'message', from: msg.from_po || 'human', to: poName,
419
+ content: msg.content || '', summary: trunc(msg.content||'',80),
420
+ sessionId: session.id, depth, ts: msg.created_at, usage: null, isStructural: false
421
+ });
422
+ }
423
+ if (msg.role === 'assistant') {
424
+ if (msg.tool_calls) {
425
+ for (const tc of msg.tool_calls) {
426
+ const tcName = tc.name||'';
427
+ const tcId = tc.id||'';
428
+ const isDeleg = delegKids[tcName] && delegKids[tcName].length > 0;
429
+ if (isDeleg) {
430
+ delegCallIds.add(tcId);
431
+ evts.push({
432
+ type: 'delegation', from: poName, to: tcName,
433
+ content: JSON.stringify(tc.arguments||{}), summary: 'delegate \u2192 '+tcName,
434
+ sessionId: session.id, depth, ts: msg.created_at, usage: msg.usage, isStructural: false
435
+ });
436
+ const child = delegKids[tcName].shift();
437
+ evts.push(...extractEvents(child, depth+1));
438
+ evts.push({
439
+ type: 'delegation_return', from: tcName, to: poName,
440
+ content: '', summary: tcName+' \u2192 return',
441
+ sessionId: session.id, depth, ts: msg.created_at, usage: null, isStructural: false
442
+ });
443
+ } else {
444
+ const isSt = STRUCTURAL_TOOLS.has(tcName);
445
+ evts.push({
446
+ type: isSt ? 'structural' : 'tool_call', from: poName, to: isSt ? poName : '[tools]',
447
+ content: JSON.stringify(tc.arguments||{}), summary: tcName, toolName: tcName,
448
+ sessionId: session.id, depth, ts: msg.created_at, usage: msg.usage, isStructural: isSt,
449
+ structuralType: isSt ? tcName : null, toolCallId: tcId
450
+ });
451
+ }
452
+ }
453
+ }
454
+ if (msg.content) {
455
+ evts.push({
456
+ type: 'response', from: poName, to: session.parent_po || 'human',
457
+ content: msg.content, summary: trunc(msg.content,80),
458
+ sessionId: session.id, depth, ts: msg.created_at, usage: msg.usage, isStructural: false
459
+ });
460
+ }
461
+ }
462
+ if (msg.role === 'tool') {
463
+ for (const tr of (msg.tool_results||[])) {
464
+ if (delegCallIds.has(tr.tool_call_id)) continue;
465
+ evts.push({
466
+ type: 'tool_result', from: '[tools]', to: poName,
467
+ content: tr.content||'', summary: trunc(tr.content||'',60), toolName: tr.name,
468
+ sessionId: session.id, depth, ts: msg.created_at, usage: null, isStructural: false,
469
+ toolCallId: tr.tool_call_id
470
+ });
471
+ }
472
+ }
473
+ }
474
+ return evts;
475
+ }
476
+
477
+ // --- Rendering orchestration ---
478
+ function renderAll() {
479
+ renderHeader();
480
+ renderCostBar();
481
+ renderFilterBtns();
482
+ renderSequenceView();
483
+ renderTimelineView();
484
+ renderDetail();
485
+ }
486
+
487
+ function getFilteredEvents() {
488
+ let filtered = events;
489
+ if (activeFilters.size > 0) {
490
+ filtered = filtered.filter(e => activeFilters.has(e.from) || activeFilters.has(e.to));
491
+ }
492
+ if (searchQuery) {
493
+ const q = searchQuery.toLowerCase();
494
+ filtered = filtered.filter(e => (e.content||'').toLowerCase().includes(q) || (e.summary||'').toLowerCase().includes(q) || (e.toolName||'').toLowerCase().includes(q));
495
+ }
496
+ return filtered;
497
+ }
498
+
499
+ // --- Header ---
500
+ function renderHeader() {
501
+ const s = treeData.session||{};
502
+ document.getElementById('h-name').textContent = s.name || s.po_name || 'Untitled';
503
+ const usage = sumUsageTree(treeData);
504
+ const nEvents = events.length;
505
+ const nSessions = Object.keys(allNodes).length;
506
+ document.getElementById('h-stats').innerHTML =
507
+ `<span><span class="v">${nSessions}</span> sessions</span>`+
508
+ `<span><span class="v">${nEvents}</span> events</span>`+
509
+ `<span><span class="v">${fmtNum(usage.input)}</span> in</span>`+
510
+ `<span><span class="v">${fmtNum(usage.output)}</span> out</span>`;
511
+ }
512
+
513
+ // --- Cost bar ---
514
+ function renderCostBar() {
515
+ const perPo = {};
516
+ events.forEach(e => {
517
+ if (e.usage) {
518
+ const po = e.from;
519
+ if (!perPo[po]) perPo[po] = { input:0, output:0 };
520
+ perPo[po].input += e.usage.input_tokens||0;
521
+ perPo[po].output += e.usage.output_tokens||0;
522
+ }
523
+ });
524
+ const total = Object.values(perPo).reduce((s,v) => s+v.input+v.output, 0) || 1;
525
+ const bar = document.getElementById('cost-bar-inner');
526
+ const legend = document.getElementById('cost-legend');
527
+ bar.innerHTML = '';
528
+ legend.innerHTML = '';
529
+ Object.entries(perPo).forEach(([po, v]) => {
530
+ const pct = ((v.input+v.output)/total*100);
531
+ if (pct < 0.5) return;
532
+ const seg = document.createElement('div');
533
+ seg.className = 'bar-seg';
534
+ seg.style.width = pct+'%';
535
+ seg.style.background = actorColors[po] || '#667766';
536
+ seg.title = `${po}: ${fmtNum(v.input)} in, ${fmtNum(v.output)} out`;
537
+ seg.addEventListener('click', () => toggleFilter(po));
538
+ bar.appendChild(seg);
539
+ legend.innerHTML += `<span><span class="dot" style="background:${actorColors[po]||'#667'}"></span>${esc(po)}</span>`;
540
+ });
541
+ }
542
+
543
+ // --- Filter buttons ---
544
+ function renderFilterBtns() {
545
+ const container = document.getElementById('filter-btns');
546
+ container.innerHTML = '';
547
+ actors.filter(a => a !== '[tools]').forEach(a => {
548
+ const btn = document.createElement('button');
549
+ btn.className = 'filter-btn' + (activeFilters.has(a) ? ' active' : '');
550
+ btn.textContent = a;
551
+ btn.style.borderColor = activeFilters.has(a) ? (actorColors[a]||'') : '';
552
+ btn.style.color = activeFilters.has(a) ? (actorColors[a]||'') : '';
553
+ btn.addEventListener('click', () => toggleFilter(a));
554
+ container.appendChild(btn);
555
+ });
556
+ }
557
+
558
+ function toggleFilter(actor) {
559
+ if (activeFilters.has(actor)) activeFilters.delete(actor);
560
+ else activeFilters.add(actor);
561
+ renderFilterBtns();
562
+ renderSequenceView();
563
+ renderTimelineView();
564
+ }
565
+
566
+ // --- Search ---
567
+ document.getElementById('search').addEventListener('input', e => {
568
+ searchQuery = e.target.value;
569
+ renderSequenceView();
570
+ renderTimelineView();
571
+ });
572
+
573
+ // --- Tab switching ---
574
+ document.querySelectorAll('.tab').forEach(tab => {
575
+ tab.addEventListener('click', () => {
576
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
577
+ tab.classList.add('active');
578
+ activeView = tab.dataset.view;
579
+ document.getElementById('seq-view').style.display = activeView === 'sequence' ? 'block' : 'none';
580
+ document.getElementById('timeline-view').style.display = activeView === 'timeline' ? 'block' : 'none';
581
+ });
582
+ });
583
+
584
+ // ========================================
585
+ // SEQUENCE VIEW
586
+ // ========================================
587
+ function renderSequenceView() {
588
+ const container = document.getElementById('seq-view');
589
+ const filtered = getFilteredEvents();
590
+
591
+ // Actor header
592
+ let html = '<div class="seq-actors">';
593
+ actors.forEach(a => {
594
+ const col = actorColors[a]||'#667';
595
+ const cls = activeFilters.has(a) ? ' filtered' : '';
596
+ html += `<div class="seq-actor${cls}" onclick="toggleFilter('${esc(a)}')" style="color:${col}"><span class="actor-dot" style="background:${col}"></span>${esc(a)}</div>`;
597
+ });
598
+ html += '</div>';
599
+
600
+ // Events area
601
+ html += '<div class="seq-events">';
602
+ // Lifelines
603
+ html += '<div class="seq-lifelines">';
604
+ actors.forEach(() => { html += '<div class="seq-lifeline"></div>'; });
605
+ html += '</div>';
606
+
607
+ // Group consecutive tool_call/tool_result pairs by same PO
608
+ const groups = groupToolCalls(filtered);
609
+
610
+ groups.forEach(g => {
611
+ if (g.group) {
612
+ html += renderSeqGroup(g);
613
+ } else {
614
+ html += renderSeqRow(g.event, g.originalIdx);
615
+ }
616
+ });
617
+
618
+ html += '</div>';
619
+ container.innerHTML = html;
620
+ }
621
+
622
+ function groupToolCalls(evts) {
623
+ const result = [];
624
+ let i = 0;
625
+ while (i < evts.length) {
626
+ const e = evts[i];
627
+ if ((e.type === 'tool_call' || e.type === 'tool_result') && !e.isStructural) {
628
+ // Collect consecutive tool events from the same session
629
+ const groupEvents = [];
630
+ const sessionId = e.sessionId;
631
+ while (i < evts.length && (evts[i].type === 'tool_call' || evts[i].type === 'tool_result') && evts[i].sessionId === sessionId && !evts[i].isStructural) {
632
+ groupEvents.push({ event: evts[i], originalIdx: events.indexOf(evts[i]) });
633
+ i++;
634
+ }
635
+ if (groupEvents.length > 2) {
636
+ result.push({ group: true, events: groupEvents, sessionId });
637
+ } else {
638
+ groupEvents.forEach(ge => result.push(ge));
639
+ }
640
+ } else {
641
+ result.push({ event: e, originalIdx: events.indexOf(e) });
642
+ i++;
643
+ }
644
+ }
645
+ return result;
646
+ }
647
+
648
+ function renderSeqGroup(g) {
649
+ const toolNames = [...new Set(g.events.filter(e=>e.event.type==='tool_call').map(e=>e.event.toolName||e.event.summary))];
650
+ const count = g.events.filter(e=>e.event.type==='tool_call').length;
651
+ const firstEvt = g.events[0].event;
652
+ const fromIdx = actors.indexOf(firstEvt.from);
653
+ const col = actorColors[firstEvt.from]||'#667';
654
+
655
+ let html = `<div class="seq-group">`;
656
+ html += `<div class="seq-group-header" onclick="toggleSeqGroup(this)">`;
657
+ html += `<span class="chevron">&#9654;</span>`;
658
+ html += `<span style="color:${col}">${esc(firstEvt.from)}</span>`;
659
+ html += `<span class="group-label">${count} tool calls: ${esc(toolNames.slice(0,3).join(', '))}${toolNames.length>3?'...':''}</span>`;
660
+ html += `</div>`;
661
+ html += `<div class="seq-group-body">`;
662
+ g.events.forEach(ge => { html += renderSeqRow(ge.event, ge.originalIdx); });
663
+ html += `</div></div>`;
664
+ return html;
665
+ }
666
+
667
+ function renderSeqRow(evt, idx) {
668
+ const fromIdx = actors.indexOf(evt.from);
669
+ const toIdx = actors.indexOf(evt.to);
670
+ const isActive = idx === selectedEventIdx;
671
+
672
+ let cls = `seq-row type-${evt.type}${isActive?' active':''}${evt.isStructural?' structural':''}`;
673
+ let html = `<div class="${cls}" data-idx="${idx}" onclick="selectEvent(${idx})">`;
674
+ html += `<div class="row-content">`;
675
+
676
+ if (evt.isStructural) {
677
+ // Structural badge on actor's lifeline
678
+ const x = fromIdx >= 0 ? ((fromIdx + 0.5) / actors.length * 100) : 50;
679
+ const col = actorColors[evt.from]||'#667';
680
+ html += `<div class="seq-structural-badge" style="left:calc(${x}% + 12px);background:rgba(255,68,85,0.15);color:var(--red);">${esc(evt.structuralType||evt.summary)}</div>`;
681
+ } else if (fromIdx >= 0 && toIdx >= 0 && fromIdx !== toIdx) {
682
+ // Arrow between two different actors
683
+ const left = Math.min(fromIdx, toIdx);
684
+ const right = Math.max(fromIdx, toIdx);
685
+ const goesRight = toIdx > fromIdx;
686
+ const leftPct = ((left + 0.5) / actors.length * 100);
687
+ const rightPct = ((right + 0.5) / actors.length * 100);
688
+ const col = actorColors[evt.from]||'#667';
689
+ const labelText = evt.type === 'tool_result' ? (evt.toolName||'\u2190') : evt.summary;
690
+
691
+ html += `<div class="seq-arrow-container" style="left:${leftPct}%;width:${rightPct-leftPct}%;">`;
692
+ if (!goesRight) html += `<div class="seq-arrow-head left" style="border-right-color:${col}"></div>`;
693
+ html += `<div class="seq-arrow-line" style="background:${col}">`;
694
+ html += `<div class="seq-arrow-label ${goesRight?'above-right':'above-left'}" style="color:${col}">${esc(trunc(labelText,40))}</div>`;
695
+ html += `</div>`;
696
+ if (goesRight) html += `<div class="seq-arrow-head right" style="border-left-color:${col}"></div>`;
697
+ html += `</div>`;
698
+ } else if (fromIdx >= 0 && fromIdx === toIdx) {
699
+ // Self-arrow (rare)
700
+ const x = ((fromIdx + 0.5) / actors.length * 100);
701
+ const col = actorColors[evt.from]||'#667';
702
+ html += `<div class="seq-self-arrow" style="left:calc(${x}% + 4px);border-color:${col}"></div>`;
703
+ html += `<div class="seq-structural-badge" style="left:calc(${x}% + 40px);background:transparent;color:${col}">${esc(trunc(evt.summary,30))}</div>`;
704
+ }
705
+
706
+ html += `</div></div>`;
707
+ return html;
708
+ }
709
+
710
+ function toggleSeqGroup(header) {
711
+ header.classList.toggle('expanded');
712
+ header.nextElementSibling.classList.toggle('expanded');
713
+ }
714
+
715
+ // ========================================
716
+ // TIMELINE VIEW
717
+ // ========================================
718
+ function renderTimelineView() {
719
+ const container = document.getElementById('timeline-view');
720
+ const filtered = getFilteredEvents();
721
+
722
+ let html = '';
723
+ filtered.forEach((evt, fi) => {
724
+ const idx = events.indexOf(evt);
725
+ const isActive = idx === selectedEventIdx;
726
+ const col = actorColors[evt.from]||'#667';
727
+ const indent = evt.depth * 16;
728
+ const typeBadge = evt.type === 'delegation' || evt.type === 'delegation_return' ? '<span class="tl-type-badge delegation">deleg</span>'
729
+ : evt.isStructural ? '<span class="tl-type-badge structural">'+esc(evt.structuralType||'struct')+'</span>'
730
+ : (evt.type === 'tool_call' || evt.type === 'tool_result') ? '<span class="tl-type-badge tool">'+ esc(evt.toolName||'tool')+'</span>' : '';
731
+
732
+ html += `<div class="tl-event${isActive?' active':''}${evt.isStructural?' structural':''}" data-idx="${idx}" onclick="selectEvent(${idx})">`;
733
+ html += `<div class="tl-idx">${idx}</div>`;
734
+ html += `<div class="tl-time">${fmtTime(evt.ts)}</div>`;
735
+ html += `<div class="tl-actor-dot" style="background:${col};margin-left:${indent}px"></div>`;
736
+ html += `<div class="tl-body">`;
737
+ html += `<div class="tl-header-line">`;
738
+ html += `<span class="tl-from" style="color:${col}">${esc(evt.from)}</span>`;
739
+ html += `<span class="tl-arrow">\u2192</span>`;
740
+ html += `<span class="tl-to" style="color:${actorColors[evt.to]||'#667'}">${esc(evt.to)}</span>`;
741
+ html += typeBadge;
742
+ html += `</div>`;
743
+ if (evt.summary && evt.type !== 'tool_result') {
744
+ html += `<div class="tl-preview">${esc(evt.summary)}</div>`;
745
+ }
746
+ if (evt.usage) {
747
+ html += `<div class="tl-usage">${fmtNum(evt.usage.input_tokens||0)} in / ${fmtNum(evt.usage.output_tokens||0)} out</div>`;
748
+ }
749
+ html += `</div></div>`;
750
+ });
751
+
752
+ container.innerHTML = html || '<div class="detail-empty"><div>No events match</div></div>';
753
+ }
754
+
755
+ // ========================================
756
+ // DETAIL VIEW
757
+ // ========================================
758
+ function selectEvent(idx) {
759
+ selectedEventIdx = idx;
760
+ const evt = events[idx];
761
+ if (!evt) return;
762
+
763
+ // Determine which session to show
764
+ const sessionId = evt.sessionId;
765
+ selectedSessionId = sessionId;
766
+
767
+ // Highlight in sequence/timeline
768
+ document.querySelectorAll('.seq-row,.tl-event').forEach(el => {
769
+ el.classList.toggle('active', parseInt(el.dataset.idx) === idx);
770
+ });
771
+
772
+ renderDetail();
773
+
774
+ // Scroll the active row into view in the current view
775
+ const activeRow = document.querySelector(activeView === 'sequence' ? '.seq-row.active' : '.tl-event.active');
776
+ if (activeRow) activeRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
777
+ }
778
+
779
+ function renderDetail() {
780
+ const container = document.getElementById('detail-view');
781
+ if (!selectedSessionId) {
782
+ container.innerHTML = '<div class="detail-empty"><div class="icon">&larr;</div><div>Select an event</div></div>';
783
+ return;
784
+ }
785
+
786
+ const node = allNodes[selectedSessionId];
787
+ if (!node) { container.innerHTML = '<div class="detail-empty"><div>Session not found</div></div>'; return; }
788
+
789
+ const session = node.session||{};
790
+ const type = session.thread_type||'root';
791
+ const usage = sumUsageTree(node);
792
+ const messages = node.messages||[];
793
+
794
+ // Build breadcrumbs
795
+ const crumbs = buildBreadcrumbs(selectedSessionId);
796
+
797
+ let html = '';
798
+
799
+ // Breadcrumbs
800
+ if (crumbs.length > 1) {
801
+ html += '<div class="breadcrumbs">';
802
+ crumbs.forEach((c, i) => {
803
+ if (i > 0) html += '<span class="breadcrumb-sep">/</span>';
804
+ const isCurrent = c.id === selectedSessionId;
805
+ html += `<span class="breadcrumb${isCurrent?' current':''}" onclick="${isCurrent?'':`navigateToSession('${c.id}')`}">${esc(c.name)}</span>`;
806
+ });
807
+ html += '</div>';
808
+ }
809
+
810
+ // Header
811
+ html += `<div class="detail-header">`;
812
+ html += `<div style="display:flex;align-items:center">`;
813
+ html += `<span class="po-name">${esc(session.po_name||'?')}</span>`;
814
+ html += `<span class="type-badge badge-${type}">${type}</span>`;
815
+ if (session.parent_po) html += `<span class="parent-info">from <em>${esc(session.parent_po)}</em></span>`;
816
+ html += `</div>`;
817
+ html += `<div class="stats"><span><span class="v">${fmtNum(usage.input)}</span> in</span><span><span class="v">${fmtNum(usage.output)}</span> out</span></div>`;
818
+ html += `</div>`;
819
+
820
+ // Messages
821
+ html += '<div class="messages">';
822
+
823
+ // tool_call_id -> result lookup
824
+ const toolResults = {};
825
+ messages.forEach(m => {
826
+ if (m.role === 'tool' && m.tool_results) m.tool_results.forEach(tr => { toolResults[tr.tool_call_id] = tr; });
827
+ });
828
+
829
+ // delegation children lookup
830
+ const delegKids = {};
831
+ (node.children||[]).forEach(c => {
832
+ const cp = c.session?.po_name;
833
+ if (c.session?.thread_type === 'delegation' && cp) {
834
+ if (!delegKids[cp]) delegKids[cp] = [];
835
+ delegKids[cp].push(c);
836
+ }
837
+ });
838
+
839
+ for (const msg of messages) {
840
+ if (msg.role === 'tool') continue;
841
+
842
+ if (msg.role === 'user') {
843
+ const fromPo = msg.from_po && msg.from_po !== 'human';
844
+ html += `<div class="msg user${fromPo?' from-po':''}">`;
845
+ html += `<div class="msg-head"><span class="msg-sender ${fromPo?'delegator':'human'}">${esc(fromPo?msg.from_po:'human')}</span>`;
846
+ html += `<span class="msg-ts">${fmtTime(msg.created_at)}</span></div>`;
847
+ html += `<div class="msg-body">${esc(msg.content||'')}</div></div>`;
848
+ }
849
+
850
+ if (msg.role === 'assistant') {
851
+ if (msg.content) {
852
+ html += `<div class="msg assistant">`;
853
+ html += `<div class="msg-head"><span class="msg-sender assistant">${esc(session.po_name||'assistant')}</span>`;
854
+ html += `<span class="msg-ts">${fmtTime(msg.created_at)}</span>`;
855
+ if (msg.usage) html += `<span class="msg-tokens">${fmtNum(msg.usage.input_tokens||0)} in / ${fmtNum(msg.usage.output_tokens||0)} out</span>`;
856
+ html += `</div>`;
857
+ html += `<div class="msg-body">${esc(msg.content)}</div></div>`;
858
+ }
859
+
860
+ if (msg.tool_calls) {
861
+ for (const tc of msg.tool_calls) {
862
+ const tcName = tc.name||'';
863
+ const tcArgs = tc.arguments||{};
864
+ const tcId = tc.id||'';
865
+ const result = toolResults[tcId];
866
+ const isSt = STRUCTURAL_TOOLS.has(tcName);
867
+
868
+ html += `<div class="tc">`;
869
+ html += `<div class="tc-head" onclick="toggleTc(this)">`;
870
+ html += `<span class="chv">&#9654;</span>`;
871
+ html += `<span class="tc-name" ${isSt?'style="color:var(--red)"':''}>${esc(tcName)}</span>`;
872
+ html += `<span class="tc-label">${isSt?'structural':'tool call'}</span>`;
873
+ html += `</div>`;
874
+ html += `<div class="tc-body">`;
875
+ html += `<div class="tc-section"><div class="tc-section-label">Arguments</div><pre>${esc(JSON.stringify(tcArgs,null,2))}</pre></div>`;
876
+ if (result) html += `<div class="tc-section"><div class="tc-section-label">Result</div><div class="tc-result">${esc(result.content||'')}</div></div>`;
877
+ html += `</div></div>`;
878
+
879
+ // Delegation marker
880
+ if (delegKids[tcName] && delegKids[tcName].length > 0) {
881
+ const child = delegKids[tcName].shift();
882
+ if (child) {
883
+ const cid = child.session?.id;
884
+ const cMsgs = (child.messages||[]).filter(m=>m.role!=='tool').length;
885
+ html += `<div class="deleg-marker" onclick="navigateToSession('${cid}')">`;
886
+ html += `<span>\u2192</span> <strong>Delegated to ${esc(tcName)}</strong>`;
887
+ html += `<span class="dm-info">${cMsgs} messages</span>`;
888
+ html += `</div>`;
889
+ }
890
+ }
891
+ }
892
+ }
893
+ }
894
+ }
895
+
896
+ html += '</div>';
897
+ container.innerHTML = html;
898
+ container.scrollTop = 0;
899
+ }
900
+
901
+ function toggleTc(head) {
902
+ head.classList.toggle('open');
903
+ head.nextElementSibling.classList.toggle('open');
904
+ }
905
+
906
+ function navigateToSession(sessionId) {
907
+ selectedSessionId = sessionId;
908
+ // Find first event for this session
909
+ const idx = events.findIndex(e => e.sessionId === sessionId);
910
+ if (idx >= 0) selectedEventIdx = idx;
911
+ renderDetail();
912
+ // Highlight in views
913
+ document.querySelectorAll('.seq-row,.tl-event').forEach(el => {
914
+ el.classList.toggle('active', parseInt(el.dataset.idx) === selectedEventIdx);
915
+ });
916
+ }
917
+
918
+ function buildBreadcrumbs(sessionId) {
919
+ const crumbs = [];
920
+ let current = allNodes[sessionId];
921
+ while (current) {
922
+ const s = current.session||{};
923
+ crumbs.unshift({ id: s.id, name: s.po_name||'?' });
924
+ // Find parent
925
+ if (s.parent_session_id && allNodes[s.parent_session_id]) {
926
+ current = allNodes[s.parent_session_id];
927
+ } else if (current._parent) {
928
+ current = current._parent;
929
+ // Check if this is really the parent by matching session
930
+ const ps = current.session;
931
+ if (ps && ps.id && !crumbs.find(c=>c.id===ps.id)) {
932
+ continue;
933
+ } else break;
934
+ } else break;
935
+ }
936
+ return crumbs;
937
+ }
938
+
939
+ // --- Utility ---
940
+ function sumUsageTree(node) {
941
+ let input=0, output=0;
942
+ (node.messages||[]).forEach(m => { if(m.usage){ input+=m.usage.input_tokens||0; output+=m.usage.output_tokens||0; }});
943
+ (node.children||[]).forEach(c => { const u=sumUsageTree(c); input+=u.input; output+=u.output; });
944
+ return {input,output};
945
+ }
946
+ function fmtNum(n) { if(n>=1e6) return (n/1e6).toFixed(1)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'K'; return ''+n; }
947
+ function fmtTime(iso) { if(!iso) return ''; try { return new Date(iso).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}); } catch{return '';} }
948
+ function trunc(s,n) { return s && s.length>n ? s.substring(0,n)+'...' : (s||''); }
949
+ function esc(s) { if(!s) return ''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
950
+
951
+ // ========================================
952
+ // SAMPLE DATA
953
+ // ========================================
954
+ function generateSampleData() {
955
+ const t = n => new Date(Date.now() - n*1000).toISOString();
956
+ return {
957
+ session: { id:"s1", po_name:"solver", name:"Analyze data pipeline", thread_type:"root", parent_po:null, created_at:t(600) },
958
+ messages: [
959
+ { role:"user", content:"Analyze the data pipeline in /src/pipeline and fix the ETL issue.", from_po:null, created_at:t(600) },
960
+ { role:"assistant", content:null,
961
+ tool_calls:[{id:"c1",name:"reader",arguments:{message:"Read and summarize all files in /src/pipeline/"}}],
962
+ usage:{input_tokens:1250,output_tokens:45,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(595) },
963
+ { role:"tool", tool_results:[{tool_call_id:"c1",name:"reader",content:"3 stages: extract (PostgreSQL), transform (cleaning), load (warehouse). Key issue: transform.py drops ~12% of rows with null timestamps."}], created_at:t(560) },
964
+ { role:"assistant", content:null,
965
+ tool_calls:[{id:"c2",name:"read_file",arguments:{path:"/src/pipeline/transform.py"}}],
966
+ usage:{input_tokens:1800,output_tokens:32,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(555) },
967
+ { role:"tool", tool_results:[{tool_call_id:"c2",name:"read_file",content:"import pandas as pd\nfrom datetime import datetime\n\ndef transform(df):\n df = df.dropna(subset=['created_at'])\n df['created_at'] = pd.to_datetime(df['created_at'])\n df['amount'] = df['amount'].clip(lower=0)\n df['status'] = df['status'].str.lower()\n return df"}], created_at:t(553) },
968
+ { role:"assistant", content:null,
969
+ tool_calls:[{id:"c3",name:"write_file",arguments:{path:"/src/pipeline/transform.py",content:"import pandas as pd\nfrom datetime import datetime\n\ndef transform(df):\n df['created_at'] = df['created_at'].fillna(df['updated_at'])\n df['created_at'] = pd.to_datetime(df['created_at'])\n df['amount'] = df['amount'].clip(lower=0)\n df['status'] = df['status'].str.lower()\n return df"}}],
970
+ usage:{input_tokens:2200,output_tokens:60,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(545) },
971
+ { role:"tool", tool_results:[{tool_call_id:"c3",name:"write_file",content:"File written successfully"}], created_at:t(543) },
972
+ { role:"assistant", content:null,
973
+ tool_calls:[{id:"c4",name:"validator",arguments:{message:"Run the test suite for /src/pipeline/ and verify the transform fix handles null timestamps correctly."}}],
974
+ usage:{input_tokens:2500,output_tokens:55,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(540) },
975
+ { role:"tool", tool_results:[{tool_call_id:"c4",name:"validator",content:"All 12 tests pass. Null timestamp handling verified: 0 rows dropped, fallback to updated_at working correctly."}], created_at:t(500) },
976
+ { role:"assistant", content:null,
977
+ tool_calls:[{id:"c5",name:"add_capability",arguments:{name:"solver",capability:"write_file"}}],
978
+ usage:{input_tokens:2800,output_tokens:30,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(495) },
979
+ { role:"tool", tool_results:[{tool_call_id:"c5",name:"add_capability",content:"Capability write_file added to solver"}], created_at:t(494) },
980
+ { role:"assistant", content:"Fixed the ETL pipeline issue.\n\n**Problem:** `transform.py` used `df.dropna(subset=['created_at'])` which silently dropped ~12% of records with null timestamps.\n\n**Fix:** Replaced with `df['created_at'].fillna(df['updated_at'])` to use the `updated_at` field as a fallback instead of discarding rows.\n\n**Verification:** All 12 tests pass. The validator confirmed 0 rows are now dropped and the fallback works correctly.\n\nI've also added `write_file` to my capabilities for future code fixes.",
981
+ usage:{input_tokens:3000,output_tokens:120,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(490) }
982
+ ],
983
+ children: [
984
+ {
985
+ session: { id:"s2", po_name:"reader", name:"Read pipeline files", thread_type:"delegation", parent_po:"solver", created_at:t(595) },
986
+ messages: [
987
+ { role:"user", content:"Read and summarize all files in /src/pipeline/", from_po:"solver", created_at:t(595) },
988
+ { role:"assistant", content:null,
989
+ tool_calls:[{id:"r1",name:"list_files",arguments:{path:"/src/pipeline/"}}],
990
+ usage:{input_tokens:420,output_tokens:18,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(593) },
991
+ { role:"tool", tool_results:[{tool_call_id:"r1",name:"list_files",content:"extract.py\ntransform.py\nload.py\nconfig.yml\n__init__.py"}], created_at:t(592) },
992
+ { role:"assistant", content:null,
993
+ tool_calls:[
994
+ {id:"r2",name:"read_file",arguments:{path:"/src/pipeline/extract.py"}},
995
+ {id:"r3",name:"read_file",arguments:{path:"/src/pipeline/transform.py"}},
996
+ {id:"r4",name:"read_file",arguments:{path:"/src/pipeline/load.py"}}
997
+ ],
998
+ usage:{input_tokens:680,output_tokens:42,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(590) },
999
+ { role:"tool", tool_results:[
1000
+ {tool_call_id:"r2",name:"read_file",content:"import psycopg2\n\ndef extract(config):\n conn = psycopg2.connect(**config['db'])\n cursor = conn.cursor()\n cursor.execute('SELECT * FROM events ORDER BY id')\n while True:\n rows = cursor.fetchmany(1000)\n if not rows: break\n yield rows"},
1001
+ {tool_call_id:"r3",name:"read_file",content:"import pandas as pd\nfrom datetime import datetime\n\ndef transform(df):\n df = df.dropna(subset=['created_at'])\n df['created_at'] = pd.to_datetime(df['created_at'])\n df['amount'] = df['amount'].clip(lower=0)\n df['status'] = df['status'].str.lower()\n return df"},
1002
+ {tool_call_id:"r4",name:"read_file",content:"from sqlalchemy import create_engine\n\ndef load(df, config):\n engine = create_engine(config['warehouse_url'])\n df.to_sql('events_clean', engine, if_exists='append', index=False, method='multi', chunksize=500)"}
1003
+ ], created_at:t(585) },
1004
+ { role:"assistant",
1005
+ content:"3 stages: extract (PostgreSQL), transform (cleaning), load (warehouse). Key issue: transform.py drops ~12% of rows with null timestamps.",
1006
+ usage:{input_tokens:1400,output_tokens:95,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(565) }
1007
+ ],
1008
+ children: []
1009
+ },
1010
+ {
1011
+ session: { id:"s3", po_name:"validator", name:"Validate fix", thread_type:"delegation", parent_po:"solver", created_at:t(540) },
1012
+ messages: [
1013
+ { role:"user", content:"Run the test suite for /src/pipeline/ and verify the transform fix handles null timestamps correctly.", from_po:"solver", created_at:t(540) },
1014
+ { role:"assistant", content:null,
1015
+ tool_calls:[{id:"v1",name:"list_files",arguments:{path:"/src/pipeline/tests/"}}],
1016
+ usage:{input_tokens:350,output_tokens:15,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(538) },
1017
+ { role:"tool", tool_results:[{tool_call_id:"v1",name:"list_files",content:"test_extract.py\ntest_transform.py\ntest_load.py\nconftest.py"}], created_at:t(537) },
1018
+ { role:"assistant", content:null,
1019
+ tool_calls:[{id:"v2",name:"read_file",arguments:{path:"/src/pipeline/tests/test_transform.py"}}],
1020
+ usage:{input_tokens:500,output_tokens:20,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(535) },
1021
+ { role:"tool", tool_results:[{tool_call_id:"v2",name:"read_file",content:"import pytest\nimport pandas as pd\nfrom pipeline.transform import transform\n\ndef test_null_timestamps():\n df = pd.DataFrame({'created_at': [None, '2024-01-01'], 'updated_at': ['2024-02-01', '2024-01-01'], 'amount': [100, 200], 'status': ['ACTIVE', 'inactive']})\n result = transform(df)\n assert len(result) == 2\n assert result.iloc[0]['created_at'] == pd.Timestamp('2024-02-01')"}], created_at:t(533) },
1022
+ { role:"assistant", content:null,
1023
+ tool_calls:[
1024
+ {id:"v3",name:"read_file",arguments:{path:"/src/pipeline/transform.py"}},
1025
+ {id:"v4",name:"read_file",arguments:{path:"/src/pipeline/tests/conftest.py"}}
1026
+ ],
1027
+ usage:{input_tokens:900,output_tokens:28,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(520) },
1028
+ { role:"tool", tool_results:[
1029
+ {tool_call_id:"v3",name:"read_file",content:"import pandas as pd\nfrom datetime import datetime\n\ndef transform(df):\n df['created_at'] = df['created_at'].fillna(df['updated_at'])\n df['created_at'] = pd.to_datetime(df['created_at'])\n df['amount'] = df['amount'].clip(lower=0)\n df['status'] = df['status'].str.lower()\n return df"},
1030
+ {tool_call_id:"v4",name:"read_file",content:"import pytest\n\n@pytest.fixture\ndef sample_data():\n return {'events': [{'id': 1, 'created_at': None, 'updated_at': '2024-01-15'}]}"}
1031
+ ], created_at:t(515) },
1032
+ { role:"assistant",
1033
+ content:"All 12 tests pass. Null timestamp handling verified: 0 rows dropped, fallback to updated_at working correctly.",
1034
+ usage:{input_tokens:1200,output_tokens:40,model:"claude-sonnet-4-5",provider:"anthropic"}, created_at:t(505) }
1035
+ ],
1036
+ children: []
1037
+ }
1038
+ ]
1039
+ };
1040
+ }
1041
+ </script>
1042
+ </body>
1043
+ </html>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prompt_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Werner
@@ -336,6 +336,7 @@ files:
336
336
  - templates/writer/objects/coordinator.md
337
337
  - templates/writer/objects/editor.md
338
338
  - templates/writer/objects/researcher.md
339
+ - tools/thread-explorer.html
339
340
  homepage: https://github.com/works-on-your-machine/prompt_objects
340
341
  licenses:
341
342
  - MIT