tanuki-telemetry 1.1.0

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.
Files changed (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,308 @@
1
+ import { useState, useRef, useEffect, type ReactNode } from "react"
2
+ import gsap from "gsap"
3
+ import { AlertTriangle, CheckCircle2, HelpCircle, ArrowRight, BookOpen, ChevronDown, ChevronRight } from "lucide-react"
4
+ import type { Insight } from "@/types"
5
+ import { useKnowledge } from "@/hooks/useKnowledge"
6
+ import { cn } from "@/lib/utils"
7
+ import type { WsMessage } from "@/hooks/useWebSocket"
8
+
9
+ const typeIcons: Record<string, ReactNode> = {
10
+ mistake: <AlertTriangle size={10} />,
11
+ success_pattern: <CheckCircle2 size={10} />,
12
+ codebase_gotcha: <HelpCircle size={10} />,
13
+ optimization: <ArrowRight size={10} />,
14
+ rule_learned: <BookOpen size={10} />,
15
+ }
16
+
17
+ const typeColors: Record<string, string> = {
18
+ mistake: "border-error/30 text-error",
19
+ success_pattern: "border-accent/30 text-accent",
20
+ codebase_gotcha: "border-warning/30 text-warning",
21
+ optimization: "border-info/30 text-info",
22
+ rule_learned: "border-purple/30 text-purple",
23
+ }
24
+
25
+ const typeBorderColors: Record<string, string> = {
26
+ mistake: "border-l-error/40",
27
+ success_pattern: "border-l-accent/40",
28
+ codebase_gotcha: "border-l-warning/40",
29
+ optimization: "border-l-info/40",
30
+ rule_learned: "border-l-purple/40",
31
+ }
32
+
33
+ const typeLabels: Record<string, string> = {
34
+ mistake: "MISTAKES",
35
+ success_pattern: "PATTERNS",
36
+ codebase_gotcha: "GOTCHAS",
37
+ optimization: "OPTIMIZATIONS",
38
+ rule_learned: "RULES",
39
+ }
40
+
41
+ export function KnowledgePage({ wsMessages = [] }: { wsMessages?: WsMessage[] }) {
42
+ const [selectedCategory, setSelectedCategory] = useState<string | undefined>()
43
+ const [selectedType, setSelectedType] = useState<string | undefined>()
44
+ const [expandedId, setExpandedId] = useState<number | null>(null)
45
+ const { data, loading, reload } = useKnowledge(selectedCategory)
46
+ const listRef = useRef<HTMLDivElement>(null)
47
+ const animatedRef = useRef(false)
48
+
49
+ useEffect(() => {
50
+ if (wsMessages.length === 0) return
51
+ const latest = wsMessages[wsMessages.length - 1]
52
+ if (latest.type === "insight") {
53
+ reload()
54
+ }
55
+ }, [wsMessages, reload])
56
+
57
+ const insights = data?.insights ?? []
58
+ const categories = data?.categories ?? []
59
+
60
+ const filtered = selectedType
61
+ ? insights.filter((i) => i.insight_type === selectedType)
62
+ : insights
63
+
64
+ // Animate only on initial load
65
+ useEffect(() => {
66
+ if (!listRef.current || animatedRef.current || filtered.length === 0) return
67
+ animatedRef.current = true
68
+ const items = listRef.current.querySelectorAll(".k-card")
69
+ gsap.fromTo(
70
+ items,
71
+ { opacity: 0, y: 6 },
72
+ { opacity: 1, y: 0, duration: 0.2, stagger: 0.02, ease: "power2.out" }
73
+ )
74
+ }, [filtered.length > 0])
75
+
76
+ if (loading && !data) {
77
+ return (
78
+ <div className="flex-1 flex items-center justify-center text-text-dim text-xs">
79
+ loading knowledge base...
80
+ </div>
81
+ )
82
+ }
83
+
84
+ if (insights.length === 0) {
85
+ return (
86
+ <div className="flex-1 flex items-center justify-center text-text-dim text-xs">
87
+ <div className="text-center space-y-2">
88
+ <div className="text-text-muted">knowledge base is empty</div>
89
+ <div className="text-[10px]">run sessions with reflection to populate insights</div>
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ const typeCounts: Record<string, number> = {}
96
+ for (const ins of insights) {
97
+ typeCounts[ins.insight_type] = (typeCounts[ins.insight_type] || 0) + 1
98
+ }
99
+
100
+ return (
101
+ <div className="flex-1 flex overflow-hidden">
102
+ {/* Left sidebar */}
103
+ <div className="w-[300px] min-w-[260px] border-r border-border flex flex-col flex-shrink-0">
104
+ <div className="px-4 h-9 border-b border-border flex items-center justify-between sticky top-0 bg-bg z-10">
105
+ <span className="text-text-dim text-[10px]">
106
+ <span className="text-accent">$</span> knowledge/
107
+ </span>
108
+ </div>
109
+
110
+ {/* Type filters */}
111
+ <div className="px-4 py-2.5 border-b border-border">
112
+ <div className="text-[9px] text-text-dim uppercase tracking-wider mb-2 font-bold">Type</div>
113
+ <div className="space-y-px">
114
+ <FilterButton
115
+ active={!selectedType}
116
+ onClick={() => setSelectedType(undefined)}
117
+ label="ALL"
118
+ count={insights.length}
119
+ />
120
+ {Object.entries(typeLabels).map(([type, label]) => (
121
+ <FilterButton
122
+ key={type}
123
+ active={selectedType === type}
124
+ onClick={() => setSelectedType(selectedType === type ? undefined : type)}
125
+ label={label}
126
+ count={typeCounts[type] || 0}
127
+ icon={typeIcons[type]}
128
+ />
129
+ ))}
130
+ </div>
131
+ </div>
132
+
133
+ {/* Category filters */}
134
+ <div className="px-4 py-2.5 flex-1 overflow-y-auto">
135
+ <div className="text-[9px] text-text-dim uppercase tracking-wider mb-2 font-bold">Category</div>
136
+ <div className="space-y-px">
137
+ <FilterButton
138
+ active={!selectedCategory}
139
+ onClick={() => setSelectedCategory(undefined)}
140
+ label="all"
141
+ count={insights.length}
142
+ />
143
+ {categories.map((cat) => (
144
+ <FilterButton
145
+ key={cat.category}
146
+ active={selectedCategory === cat.category}
147
+ onClick={() => setSelectedCategory(selectedCategory === cat.category ? undefined : cat.category)}
148
+ label={cat.category}
149
+ count={cat.count}
150
+ meta={`${Math.round(cat.avg_confidence * 100)}%`}
151
+ />
152
+ ))}
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ {/* Main content */}
158
+ <div ref={listRef} className="flex-1 overflow-y-auto p-4 space-y-1">
159
+ {/* Summary */}
160
+ <div className="flex items-center gap-4 mb-3 text-[10px] text-text-dim px-2">
161
+ <span><span className="text-text font-bold">{filtered.length}</span> insights</span>
162
+ <span className="text-border">|</span>
163
+ <span>
164
+ avg conf <span className="text-accent font-bold">
165
+ {filtered.length > 0
166
+ ? Math.round((filtered.reduce((a, i) => a + i.confidence, 0) / filtered.length) * 100)
167
+ : 0}%
168
+ </span>
169
+ </span>
170
+ <span className="text-border">|</span>
171
+ <span>
172
+ <span className="text-text font-bold">{filtered.reduce((a, i) => a + i.times_validated, 0)}</span> validations
173
+ </span>
174
+ </div>
175
+
176
+ {filtered.map((ins) => (
177
+ <InsightCard
178
+ key={ins.id}
179
+ insight={ins}
180
+ expanded={expandedId === ins.id}
181
+ onToggle={() => setExpandedId(expandedId === ins.id ? null : ins.id)}
182
+ />
183
+ ))}
184
+ </div>
185
+ </div>
186
+ )
187
+ }
188
+
189
+ function FilterButton({
190
+ active,
191
+ onClick,
192
+ label,
193
+ count,
194
+ icon,
195
+ meta,
196
+ }: {
197
+ active: boolean
198
+ onClick: () => void
199
+ label: string
200
+ count: number
201
+ icon?: ReactNode
202
+ meta?: string
203
+ }) {
204
+ return (
205
+ <button
206
+ onClick={onClick}
207
+ className={cn(
208
+ "w-full text-left px-2 py-1.5 text-[11px] transition-colors flex items-center gap-2 border-l-2",
209
+ active
210
+ ? "bg-bg-hover text-accent border-l-accent"
211
+ : "text-text-muted hover:text-text border-l-transparent"
212
+ )}
213
+ >
214
+ {icon && <span className="flex-shrink-0">{icon}</span>}
215
+ <span className="flex-1 truncate">{label}</span>
216
+ <span className="text-[10px] text-text-dim flex-shrink-0">
217
+ {meta ? `${count} · ${meta}` : count}
218
+ </span>
219
+ </button>
220
+ )
221
+ }
222
+
223
+ function InsightCard({
224
+ insight: ins,
225
+ expanded,
226
+ onToggle,
227
+ }: {
228
+ insight: Insight
229
+ expanded: boolean
230
+ onToggle: () => void
231
+ }) {
232
+ const filePatterns = ins.file_patterns ? (JSON.parse(ins.file_patterns) as string[]) : []
233
+ const errorPatterns = ins.error_patterns ? (JSON.parse(ins.error_patterns) as string[]) : []
234
+ const pct = Math.round(ins.confidence * 100)
235
+
236
+ return (
237
+ <div
238
+ className={cn(
239
+ "k-card border border-border border-l-2 bg-bg cursor-pointer transition-colors hover:bg-bg-elevated",
240
+ typeBorderColors[ins.insight_type] || "border-l-border",
241
+ expanded && "bg-bg-elevated border-border-bright"
242
+ )}
243
+ onClick={onToggle}
244
+ >
245
+ <div className="flex items-baseline gap-3 px-3 pt-2.5 pb-2">
246
+ <span
247
+ className={cn(
248
+ "text-[10px] px-1.5 py-0.5 border font-bold font-mono flex-shrink-0",
249
+ typeColors[ins.insight_type] || "border-border text-text-dim"
250
+ )}
251
+ >
252
+ {typeIcons[ins.insight_type] || "--"}
253
+ </span>
254
+
255
+ <div className="flex-1 min-w-0">
256
+ <div className="text-[11px] font-bold text-text">{ins.title}</div>
257
+ <div className="flex items-center gap-3 mt-1 text-[10px] text-text-dim">
258
+ <span className="text-text-muted">{ins.category}</span>
259
+ <span className="text-accent font-mono">{pct}%</span>
260
+ {ins.times_validated > 0 && (
261
+ <span>{ins.times_validated}x validated</span>
262
+ )}
263
+ </div>
264
+ </div>
265
+
266
+ <span className="text-text-dim flex-shrink-0">
267
+ {expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
268
+ </span>
269
+ </div>
270
+
271
+ {expanded && (
272
+ <div data-expand className="px-3 pb-3 border-t border-border pt-2.5 ml-9">
273
+ <div className="text-[11px] text-text-muted leading-relaxed mb-3">
274
+ {ins.description}
275
+ </div>
276
+
277
+ {ins.evidence && (
278
+ <div className="mb-3">
279
+ <div className="text-[9px] text-text-dim uppercase tracking-wider mb-1 font-bold">Evidence</div>
280
+ <pre className="text-[10px] text-text-dim bg-bg border border-border p-2 overflow-auto max-h-32 whitespace-pre-wrap">
281
+ {ins.evidence}
282
+ </pre>
283
+ </div>
284
+ )}
285
+
286
+ <div className="flex gap-4 flex-wrap text-[10px]">
287
+ {filePatterns.length > 0 && (
288
+ <div>
289
+ <span className="text-accent mr-1">files:</span>
290
+ <span className="text-text-muted">{filePatterns.join(", ")}</span>
291
+ </div>
292
+ )}
293
+ {errorPatterns.length > 0 && (
294
+ <div>
295
+ <span className="text-error mr-1">errors:</span>
296
+ <span className="text-text-muted">{errorPatterns.join(", ")}</span>
297
+ </div>
298
+ )}
299
+ <div>
300
+ <span className="text-text-dim mr-1">session:</span>
301
+ <span className="text-text-muted font-mono text-[9px]">{ins.session_id.slice(0, 8)}</span>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ )}
306
+ </div>
307
+ )
308
+ }
@@ -0,0 +1,55 @@
1
+ import { useRef, useEffect } from "react"
2
+ import gsap from "gsap"
3
+
4
+ export function LoginPage() {
5
+ const ref = useRef<HTMLDivElement>(null)
6
+
7
+ useEffect(() => {
8
+ if (!ref.current) return
9
+ gsap.fromTo(
10
+ ref.current.children,
11
+ { opacity: 0, y: 12 },
12
+ { opacity: 1, y: 0, duration: 0.5, stagger: 0.15, ease: "power2.out" }
13
+ )
14
+ }, [])
15
+
16
+ return (
17
+ <div ref={ref} className="h-screen flex flex-col items-center justify-center bg-bg">
18
+ {/* Scanline overlay */}
19
+ <div className="pointer-events-none fixed inset-0 z-[100] opacity-[0.03]">
20
+ <div
21
+ className="w-full h-px bg-accent"
22
+ style={{ animation: "scanline 8s linear infinite" }}
23
+ />
24
+ </div>
25
+
26
+ <div className="text-center space-y-8">
27
+ <div className="space-y-2">
28
+ <h1 className="text-accent text-lg font-bold tracking-widest font-mono">
29
+ TANUKI
30
+ </h1>
31
+ <p className="text-text-dim text-[11px] font-mono">
32
+ autonomous workflow monitor
33
+ </p>
34
+ </div>
35
+
36
+ <a
37
+ href="/auth/google"
38
+ className="inline-flex items-center gap-3 px-6 py-3 border border-accent text-accent hover:bg-accent hover:text-bg transition-colors duration-200 text-xs font-mono tracking-wider"
39
+ >
40
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
41
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
42
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
43
+ <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
44
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
45
+ </svg>
46
+ SIGN IN WITH GOOGLE
47
+ </a>
48
+ </div>
49
+
50
+ <div className="absolute bottom-4 text-[10px] text-text-dim font-mono">
51
+ access restricted to authorized team members
52
+ </div>
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,163 @@
1
+ import { useRef, useEffect, type ReactNode } from "react"
2
+ import gsap from "gsap"
3
+ import { Circle, Play, CheckCircle2, MinusCircle, AlertTriangle } from "lucide-react"
4
+ import type { PlanStep } from "@/types"
5
+ import { formatDuration, cn } from "@/lib/utils"
6
+
7
+ interface Props {
8
+ steps: PlanStep[]
9
+ }
10
+
11
+ const statusIcons: Record<string, ReactNode> = {
12
+ pending: <Circle size={10} />,
13
+ in_progress: <Play size={10} fill="currentColor" />,
14
+ completed: <CheckCircle2 size={10} />,
15
+ skipped: <MinusCircle size={10} />,
16
+ failed: <AlertTriangle size={10} />,
17
+ }
18
+
19
+ const statusColors: Record<string, string> = {
20
+ pending: "text-text-dim",
21
+ in_progress: "text-warning",
22
+ completed: "text-accent",
23
+ skipped: "text-text-dim",
24
+ failed: "text-error",
25
+ }
26
+
27
+ export function PlanProgress({ steps }: Props) {
28
+ const ref = useRef<HTMLDivElement>(null)
29
+ const completed = steps.filter((s) => s.status === "completed").length
30
+ const failed = steps.filter((s) => s.status === "failed").length
31
+ const total = steps.length
32
+ const pct = total > 0 ? Math.round((completed / total) * 100) : 0
33
+
34
+ const prevCountRef = useRef(0)
35
+
36
+ useEffect(() => {
37
+ if (!ref.current) return
38
+ if (steps.length === prevCountRef.current) return
39
+ const isInitial = prevCountRef.current === 0
40
+ prevCountRef.current = steps.length
41
+ if (isInitial) {
42
+ const items = ref.current.querySelectorAll(".plan-step")
43
+ gsap.fromTo(
44
+ items,
45
+ { opacity: 0, x: -6 },
46
+ { opacity: 1, x: 0, duration: 0.2, stagger: 0.04, ease: "power2.out" }
47
+ )
48
+ }
49
+ }, [steps])
50
+
51
+ return (
52
+ <div ref={ref} className="mt-2">
53
+ {/* Progress bar */}
54
+ <div className="flex items-center gap-3 mb-3">
55
+ <div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
56
+ <div
57
+ className="h-full bg-accent rounded-full transition-all duration-500"
58
+ style={{ width: `${pct}%` }}
59
+ />
60
+ </div>
61
+ <span className="text-[10px] text-text-muted tabular-nums w-16 text-right">
62
+ {completed}/{total}
63
+ {failed > 0 && (
64
+ <span className="text-error ml-1">({failed} fail)</span>
65
+ )}
66
+ </span>
67
+ </div>
68
+
69
+ {/* Steps */}
70
+ <div className="space-y-0">
71
+ {steps.map((step) => {
72
+ const isSubStep = step.parent_step != null
73
+ const fileTargets = step.file_targets
74
+ ? (JSON.parse(step.file_targets) as string[])
75
+ : []
76
+
77
+ return (
78
+ <div
79
+ key={step.id}
80
+ className={cn(
81
+ "plan-step flex items-baseline gap-2 pt-2 pb-1.5 px-2 border-l-2 transition-colors duration-200",
82
+ isSubStep && "ml-6",
83
+ step.status === "in_progress"
84
+ ? "border-l-warning bg-warning/[0.03]"
85
+ : step.status === "completed"
86
+ ? "border-l-accent/40"
87
+ : step.status === "failed"
88
+ ? "border-l-error/40 bg-error/[0.03]"
89
+ : "border-l-border"
90
+ )}
91
+ >
92
+ {/* Status icon */}
93
+ <span
94
+ className={cn(
95
+ "text-[10px] font-mono font-bold flex-shrink-0 w-6",
96
+ statusColors[step.status],
97
+ step.status === "in_progress" && "animate-pulse"
98
+ )}
99
+ >
100
+ {statusIcons[step.status]}
101
+ </span>
102
+
103
+ {/* Step number */}
104
+ <span className="text-[10px] text-text-dim w-4 flex-shrink-0 tabular-nums">
105
+ {step.step_number}
106
+ </span>
107
+
108
+ {/* Content */}
109
+ <div className="flex-1 min-w-0">
110
+ <div className="flex items-center gap-2">
111
+ <span
112
+ className={cn(
113
+ "text-xs font-bold",
114
+ step.status === "completed"
115
+ ? "text-text line-through opacity-60"
116
+ : step.status === "in_progress"
117
+ ? "text-warning"
118
+ : step.status === "failed"
119
+ ? "text-error"
120
+ : step.status === "skipped"
121
+ ? "text-text-dim line-through"
122
+ : "text-text"
123
+ )}
124
+ >
125
+ {step.title}
126
+ </span>
127
+ {step.duration_seconds != null && (
128
+ <span className="text-[10px] text-text-dim">
129
+ {formatDuration(step.duration_seconds)}
130
+ </span>
131
+ )}
132
+ </div>
133
+
134
+ {step.description && step.status !== "completed" && (
135
+ <div className="text-[10px] text-text-muted mt-0.5">
136
+ {step.description}
137
+ </div>
138
+ )}
139
+
140
+ {step.outcome && (
141
+ <div
142
+ className={cn(
143
+ "text-[10px] mt-0.5",
144
+ step.status === "failed" ? "text-error/80" : "text-accent/70"
145
+ )}
146
+ >
147
+ {"\u2192"} {step.outcome}
148
+ </div>
149
+ )}
150
+
151
+ {fileTargets.length > 0 && step.status !== "completed" && (
152
+ <div className="text-[9px] text-text-dim mt-0.5">
153
+ {fileTargets.join(", ")}
154
+ </div>
155
+ )}
156
+ </div>
157
+ </div>
158
+ )
159
+ })}
160
+ </div>
161
+ </div>
162
+ )
163
+ }