shraga 0.0.3 → 0.1.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.
Files changed (180) hide show
  1. package/README.md +82 -23
  2. package/defaults/agents/summarizer.md +16 -0
  3. package/defaults/agents/trace-extractor.md +84 -0
  4. package/defaults/bin/claude +45 -0
  5. package/defaults/bin/claude-revive +17 -0
  6. package/defaults/extensions/README.md +70 -0
  7. package/defaults/extensions/selftest.ext.ts +43 -0
  8. package/defaults/extensions/stripe-webhook.ext.ts +58 -0
  9. package/defaults/gmail-triage-prompt.md +42 -0
  10. package/defaults/scripts/README +4 -0
  11. package/defaults/scripts/agent-once.ts +67 -0
  12. package/defaults/scripts/backfill-slack-usernames.ts +82 -0
  13. package/defaults/scripts/notifier-throttle.ts +44 -0
  14. package/defaults/scripts/summarize-conversations.ts +5 -0
  15. package/defaults/shraga.config.ts +29 -0
  16. package/defaults/skills/add-skill.md +14 -0
  17. package/defaults/skills/artifacts.md +116 -0
  18. package/defaults/skills/code-review.md +26 -0
  19. package/defaults/skills/communications.md +54 -0
  20. package/defaults/skills/context-audit.md +87 -0
  21. package/defaults/skills/debug.md +10 -0
  22. package/defaults/skills/garden.md +179 -0
  23. package/defaults/skills/github-contributor.md +35 -0
  24. package/defaults/skills/identity.md +30 -0
  25. package/defaults/skills/mcp-server.md +62 -0
  26. package/defaults/skills/mcps-sync.md +105 -0
  27. package/defaults/skills/plan.md +9 -0
  28. package/defaults/skills/platform.md +177 -0
  29. package/defaults/skills/reconcile.md +239 -0
  30. package/defaults/skills/scheduler.md +192 -0
  31. package/defaults/skills/self-aware.md +136 -0
  32. package/defaults/skills/shraga-know.md +333 -0
  33. package/defaults/skills/stripe.md +55 -0
  34. package/defaults/skills/write-tests.md +10 -0
  35. package/defaults/skills-defaults.json +1 -0
  36. package/defaults/system-prompt.md +46 -0
  37. package/defaults/workspace/context.md +28 -0
  38. package/defaults/workspace.md +50 -0
  39. package/defaults/zdotdir/.gitignore +8 -0
  40. package/defaults/zdotdir/.zlogin +3 -0
  41. package/defaults/zdotdir/.zprofile +1 -0
  42. package/defaults/zdotdir/.zshenv +4 -0
  43. package/defaults/zdotdir/.zshrc +3 -0
  44. package/dist/client/assets/index-BoHttkMt.js +1940 -0
  45. package/dist/client/assets/index-DdibEb2O.css +10 -0
  46. package/dist/client/index.html +22 -0
  47. package/package.json +59 -14
  48. package/src/cli.ts +71 -46
  49. package/src/client/App.tsx +510 -0
  50. package/src/client/components/ArtifactCard.tsx +26 -0
  51. package/src/client/components/ArtifactPanel.tsx +138 -0
  52. package/src/client/components/AuthedImage.tsx +85 -0
  53. package/src/client/components/AutocompleteTextarea.tsx +149 -0
  54. package/src/client/components/ChatView.tsx +866 -0
  55. package/src/client/components/CliAuthConsent.tsx +98 -0
  56. package/src/client/components/ConfigPanel.tsx +328 -0
  57. package/src/client/components/ConversationHeader.tsx +156 -0
  58. package/src/client/components/ConversationPane.tsx +277 -0
  59. package/src/client/components/LoginPage.tsx +81 -0
  60. package/src/client/components/MachineStats.tsx +77 -0
  61. package/src/client/components/McpManager.tsx +209 -0
  62. package/src/client/components/MessageInput.tsx +263 -0
  63. package/src/client/components/OAuthConsent.tsx +103 -0
  64. package/src/client/components/SchedulesManager.tsx +99 -0
  65. package/src/client/components/Sidebar.tsx +235 -0
  66. package/src/client/components/SkillsManager.tsx +280 -0
  67. package/src/client/components/SmartChart.tsx +167 -0
  68. package/src/client/components/Toast.tsx +54 -0
  69. package/src/client/components/WorkspaceTree.tsx +313 -0
  70. package/src/client/components/ZoomableImage.tsx +123 -0
  71. package/src/client/components/artifact-presets.ts +10 -0
  72. package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
  73. package/src/client/components/schedules/ScheduleList.tsx +271 -0
  74. package/src/client/components/ui/accordion.tsx +50 -0
  75. package/src/client/components/ui/button.tsx +43 -0
  76. package/src/client/components/ui/dialog.tsx +82 -0
  77. package/src/client/components/ui/input.tsx +19 -0
  78. package/src/client/components/ui/scroll-area.tsx +39 -0
  79. package/src/client/components/ui/textarea.tsx +18 -0
  80. package/src/client/globals.css +51 -0
  81. package/src/client/hooks/useAgentSocket.ts +79 -0
  82. package/src/client/hooks/useArtifacts.ts +89 -0
  83. package/src/client/hooks/useAuth.ts +127 -0
  84. package/src/client/hooks/useConversation.ts +412 -0
  85. package/src/client/hooks/useDarkMode.ts +57 -0
  86. package/src/client/hooks/useIsMobile.ts +23 -0
  87. package/src/client/hooks/usePush.ts +127 -0
  88. package/src/client/hooks/useSchedules.ts +73 -0
  89. package/src/client/hooks/useUnread.ts +238 -0
  90. package/src/client/lib/desktopAttention.ts +75 -0
  91. package/src/client/lib/firebase.ts +32 -0
  92. package/src/client/lib/googleAuthNative.ts +94 -0
  93. package/src/client/lib/native.ts +43 -0
  94. package/src/client/lib/schedule-types.ts +34 -0
  95. package/src/client/lib/sessionApi.ts +58 -0
  96. package/src/client/lib/slots.tsx +79 -0
  97. package/src/client/lib/storage.ts +39 -0
  98. package/src/client/lib/utils.ts +26 -0
  99. package/src/client/lib/workspaceContext.tsx +54 -0
  100. package/src/client/lib/ws.ts +203 -0
  101. package/src/client/main.tsx +14 -0
  102. package/src/mcp-stdio-bridge.ts +70 -0
  103. package/src/scripts/summarize-conversations.ts +5 -0
  104. package/src/scripts/typecheck.ts +43 -0
  105. package/src/server/agents.ts +54 -0
  106. package/src/server/api-keys.ts +63 -0
  107. package/src/server/artifacts/artifacts.export.ts +85 -0
  108. package/src/server/artifacts/artifacts.handler.ts +93 -0
  109. package/src/server/artifacts/artifacts.routes.ts +43 -0
  110. package/src/server/artifacts/artifacts.service.ts +100 -0
  111. package/src/server/artifacts/artifacts.types.ts +31 -0
  112. package/src/server/auth.ts +262 -0
  113. package/src/server/claude.ts +394 -0
  114. package/src/server/commands.ts +21 -0
  115. package/src/server/contacts.ts +177 -0
  116. package/src/server/conversation-summarizer.ts +204 -0
  117. package/src/server/data-sync.ts +664 -0
  118. package/src/server/directives.ts +91 -0
  119. package/src/server/engine/claude-code.ts +514 -0
  120. package/src/server/engine/index.ts +41 -0
  121. package/src/server/engine/registry.ts +21 -0
  122. package/src/server/engine/shared.ts +47 -0
  123. package/src/server/engine/types.ts +48 -0
  124. package/src/server/env-resolve.ts +71 -0
  125. package/src/server/env-sanitize.ts +9 -0
  126. package/src/server/events/bus.ts +29 -0
  127. package/src/server/events/dispatcher.ts +48 -0
  128. package/src/server/events/routes.ts +19 -0
  129. package/src/server/events/types.ts +9 -0
  130. package/src/server/extensions.ts +101 -0
  131. package/src/server/features.ts +109 -0
  132. package/src/server/file-inject.ts +45 -0
  133. package/src/server/hooks.ts +142 -0
  134. package/src/server/idempotency.ts +25 -0
  135. package/src/server/index.ts +1715 -0
  136. package/src/server/integrity-audit.ts +132 -0
  137. package/src/server/mcp-catalog.ts +70 -0
  138. package/src/server/mcp-oauth.ts +198 -0
  139. package/src/server/mcp-progress.ts +45 -0
  140. package/src/server/mcp-server.ts +456 -0
  141. package/src/server/mcp-sidecar.ts +87 -0
  142. package/src/server/mcp.ts +291 -0
  143. package/src/server/model-aliases.ts +76 -0
  144. package/src/server/paths.ts +24 -0
  145. package/src/server/polls.ts +175 -0
  146. package/src/server/push/apns.ts +113 -0
  147. package/src/server/push/fcm.ts +108 -0
  148. package/src/server/push/push.ts +66 -0
  149. package/src/server/push/store.ts +84 -0
  150. package/src/server/push/triggers.ts +99 -0
  151. package/src/server/scheduler/builtins.ts +157 -0
  152. package/src/server/scheduler/engine.ts +432 -0
  153. package/src/server/scheduler/index.ts +4 -0
  154. package/src/server/scheduler/runner.ts +334 -0
  155. package/src/server/scheduler/storage.ts +98 -0
  156. package/src/server/scheduler/timing.ts +70 -0
  157. package/src/server/scheduler/types.ts +62 -0
  158. package/src/server/sdk-utils.ts +45 -0
  159. package/src/server/seed.ts +174 -0
  160. package/src/server/session-bus.ts +18 -0
  161. package/src/server/sessions.ts +559 -0
  162. package/src/server/shraga-config.ts +167 -0
  163. package/src/server/skills.ts +372 -0
  164. package/src/server/slack/api.ts +37 -0
  165. package/src/server/slack/bot.ts +391 -0
  166. package/src/server/slack/context-cache.ts +42 -0
  167. package/src/server/slack/feature.ts +59 -0
  168. package/src/server/slack/mention-rewrite.ts +59 -0
  169. package/src/server/slack/oauth.ts +102 -0
  170. package/src/server/slack/questions.ts +112 -0
  171. package/src/server/slack/sessions.ts +139 -0
  172. package/src/server/stats.ts +106 -0
  173. package/src/server/summarize.ts +11 -0
  174. package/src/server/turn-context.ts +61 -0
  175. package/src/server/unclaw-config.ts +19 -0
  176. package/src/server/unread.ts +79 -0
  177. package/src/server/user-context.ts +33 -0
  178. package/src/server/vendor-sync.ts +52 -0
  179. package/src/server/voice-provider.ts +74 -0
  180. package/src/server/workspace.ts +249 -0
@@ -0,0 +1,235 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Plus, MessageSquare, Hash, AtSign, MessageCircle } from 'lucide-react';
3
+ import { Button } from './ui/button';
4
+ import { ScrollArea } from './ui/scroll-area';
5
+ import { cn } from '@/lib/utils';
6
+ import { WorkspaceTree } from './WorkspaceTree';
7
+ import { useSlots } from '@/lib/slots';
8
+ import { MachineStats } from './MachineStats';
9
+ import type { UnreadSession } from '@/hooks/useUnread';
10
+ import type { AgentSocket } from '@/lib/ws';
11
+
12
+ interface Session {
13
+ sessionId: string;
14
+ title: string;
15
+ userEmail: string;
16
+ userName: string;
17
+ uid: string;
18
+ createdAt: number;
19
+ lastModified: number;
20
+ scope?: 'system' | 'user';
21
+ visibleTo?: string[];
22
+ slackContext?: { type: 'dm' | 'channel' | 'mention'; channelName?: string; userName?: string };
23
+ runStatus?: 'running' | 'idle';
24
+ lastStopReason?: 'max_turns_reached' | 'error' | 'aborted';
25
+ scheduleRunStatus?: 'running' | 'ok' | 'error' | 'aborted';
26
+ }
27
+
28
+ type ChatsFilter = 'mine' | 'all';
29
+
30
+ function isMine(s: Session, uid: string, email: string): boolean {
31
+ if (s.uid === uid) return true;
32
+ if (s.visibleTo?.includes(email.toLowerCase())) return true;
33
+ return false;
34
+ }
35
+
36
+ interface Props {
37
+ getToken: () => Promise<string | null>;
38
+ activeSessionId?: string;
39
+ onSelect: (sessionId: string, title?: string) => void;
40
+ onNew: () => void;
41
+ refreshKey?: number;
42
+ workspaceRefreshKey?: number;
43
+ onRefreshWorkspace: () => void;
44
+ userUid: string;
45
+ userEmail: string;
46
+ unreads?: Record<string, UnreadSession>;
47
+ busySessions?: Set<string>;
48
+ socket?: AgentSocket | null;
49
+ }
50
+
51
+ export function formatTime(ts: number): string {
52
+ const diff = Date.now() - ts;
53
+ const mins = Math.floor(diff / 60000);
54
+ if (mins < 1) return 'just now';
55
+ if (mins < 60) return `${mins}m ago`;
56
+ const hours = Math.floor(mins / 60);
57
+ if (hours < 24) return `${hours}h ago`;
58
+ const days = Math.floor(hours / 24);
59
+ if (days < 7) return `${days}d ago`;
60
+ return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
61
+ }
62
+
63
+ const iconCls = 'w-4 h-4 mt-0.5 shrink-0';
64
+
65
+ function slackIcon(s: Session) {
66
+ const ctx = s.slackContext;
67
+ if (ctx?.type === 'dm') return <MessageCircle className={`${iconCls} text-purple-400`} />;
68
+ if (ctx?.type === 'mention') return <AtSign className={`${iconCls} text-blue-400`} />;
69
+ if (ctx?.type === 'channel') return <Hash className={`${iconCls} text-green-400`} />;
70
+ if (s.sessionId.startsWith('slack-')) return <MessageSquare className={`${iconCls} text-yellow-400`} />;
71
+ return <MessageSquare className={`${iconCls} text-muted-foreground`} />;
72
+ }
73
+
74
+ function slackLabel(s: Session): string | null {
75
+ const ctx = s.slackContext;
76
+ if (ctx?.type === 'dm') return `DM${ctx.userName ? ` · ${ctx.userName}` : ''}`;
77
+ if (ctx?.type === 'mention') return `@mention${ctx.channelName ? ` · #${ctx.channelName}` : ''}`;
78
+ if (ctx?.type === 'channel') return `#${ctx.channelName || 'channel'}`;
79
+ if (s.sessionId.startsWith('slack-')) return 'Slack';
80
+ return null;
81
+ }
82
+
83
+ const FILTER_KEY = 'chats-filter';
84
+ const UNREAD_FILTER_KEY = 'chats-unread-filter';
85
+
86
+ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey, workspaceRefreshKey, onRefreshWorkspace, userUid, userEmail, unreads = {}, busySessions = new Set(), socket }: Props) {
87
+ const slots = useSlots();
88
+ const [sessions, setSessions] = useState<Session[]>([]);
89
+ const [filter, setFilter] = useState<ChatsFilter>(() => (localStorage.getItem(FILTER_KEY) as ChatsFilter) || 'mine');
90
+ const [unreadOnly, setUnreadOnly] = useState(() => localStorage.getItem(UNREAD_FILTER_KEY) === 'true');
91
+ const [version, setVersion] = useState<string>('');
92
+ const acRef = useRef<AbortController | null>(null);
93
+ const activeRef = useRef<HTMLButtonElement | null>(null);
94
+
95
+ function changeFilter(f: ChatsFilter) {
96
+ setFilter(f);
97
+ localStorage.setItem(FILTER_KEY, f);
98
+ }
99
+
100
+ function toggleUnreadOnly() {
101
+ const next = !unreadOnly;
102
+ setUnreadOnly(next);
103
+ localStorage.setItem(UNREAD_FILTER_KEY, String(next));
104
+ }
105
+
106
+ const scopeFiltered = useMemo(() =>
107
+ sessions.filter((s) => filter === 'all' || isMine(s, userUid, userEmail)),
108
+ [sessions, filter, userUid, userEmail],
109
+ );
110
+
111
+ const visibleUnreadCount = useMemo(() =>
112
+ scopeFiltered.filter((s) => unreads[s.sessionId]).length,
113
+ [scopeFiltered, unreads],
114
+ );
115
+
116
+ const filtered = useMemo(() =>
117
+ unreadOnly ? scopeFiltered.filter((s) => unreads[s.sessionId]) : scopeFiltered,
118
+ [scopeFiltered, unreadOnly, unreads],
119
+ );
120
+
121
+ useEffect(() => {
122
+ getToken().then(t => t ? fetch('/api/version', { headers: { Authorization: `Bearer ${t}` } }) : null).then(r => r?.json()).then(d => d && setVersion(d.version)).catch(() => {});
123
+ }, []);
124
+
125
+ useEffect(() => {
126
+ acRef.current?.abort();
127
+ const ac = new AbortController();
128
+ acRef.current = ac;
129
+
130
+ getToken().then((token) => {
131
+ if (!token || ac.signal.aborted) return;
132
+ fetch('/api/sessions', { signal: ac.signal, headers: { Authorization: `Bearer ${token}` } })
133
+ .then((r) => (r.ok ? r.json() : []))
134
+ .then((data) => { if (Array.isArray(data)) setSessions(data); })
135
+ .catch(() => {});
136
+ });
137
+
138
+ return () => ac.abort();
139
+ }, [getToken, refreshKey]);
140
+
141
+ useEffect(() => {
142
+ if (activeRef.current) {
143
+ activeRef.current.scrollIntoView({ block: 'nearest' });
144
+ }
145
+ }, [activeSessionId]);
146
+
147
+ return (
148
+ <div className="flex flex-col h-full bg-muted/30">
149
+ <div className="p-3">
150
+ <Button variant="outline" size="sm" className="w-full gap-2 justify-start" onClick={onNew} title="New Chat (Ctrl+N)">
151
+ <Plus className="w-4 h-4" /> New Chat
152
+ <span className="ml-auto flex items-center gap-0.5 text-[10px] text-muted-foreground/50 font-mono">⌃N</span>
153
+ </Button>
154
+ </div>
155
+
156
+ <div className="border-b shrink-0 flex flex-col max-h-[40vh]">
157
+ <WorkspaceTree getToken={getToken} onRefresh={onRefreshWorkspace} refreshKey={workspaceRefreshKey} />
158
+ </div>
159
+
160
+ <div className="flex items-center justify-between px-3 pt-2 pb-1">
161
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Chats</span>
162
+ <div className="flex gap-0.5">
163
+ {(['mine', 'all'] as const).map((f) => (
164
+ <button key={f} onClick={() => changeFilter(f)} className={cn(
165
+ 'text-[10px] px-1.5 rounded-full transition-colors capitalize leading-none',
166
+ filter === f ? 'text-foreground font-medium' : 'text-muted-foreground/60 hover:text-muted-foreground'
167
+ )}>{f}</button>
168
+ ))}
169
+ <button
170
+ onClick={toggleUnreadOnly}
171
+ className={cn(
172
+ 'text-[10px] px-1.5 rounded-full transition-colors leading-none',
173
+ unreadOnly ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-muted-foreground/60 hover:text-muted-foreground',
174
+ )}
175
+ >
176
+ unread{visibleUnreadCount > 0 && <span className="inline-block w-1.5 h-1.5 ml-1 rounded-full bg-blue-500 align-middle" />}
177
+ </button>
178
+ </div>
179
+ </div>
180
+
181
+ <ScrollArea className="flex-1">
182
+ <div className="px-2 pb-2 space-y-0.5">
183
+ {filtered.length === 0 && (
184
+ <p className="text-xs text-muted-foreground px-3 py-6 text-center">
185
+ {unreadOnly ? 'No unread conversations' : sessions.length === 0 ? 'No conversations yet' : 'No conversations match this filter'}
186
+ </p>
187
+ )}
188
+ {filtered.map((s) => {
189
+ const unread = unreads[s.sessionId];
190
+ const isBusy = busySessions.has(s.sessionId) || s.runStatus === 'running' || s.scheduleRunStatus === 'running';
191
+ const isError = !isBusy && (!!s.lastStopReason || s.scheduleRunStatus === 'error' || s.scheduleRunStatus === 'aborted');
192
+ const borderColor = isBusy ? 'border-amber-500' : isError ? 'border-red-500' : unread ? 'border-blue-500' : '';
193
+ const hasBorder = !!(isBusy || isError || unread);
194
+ return (
195
+ <button
196
+ key={s.sessionId}
197
+ ref={s.sessionId === activeSessionId ? activeRef : undefined}
198
+ onClick={() => onSelect(s.sessionId, s.title)}
199
+ className={cn(
200
+ 'w-full text-left px-3 py-2.5 text-sm transition-colors hover:bg-accent/50 group',
201
+ hasBorder ? `rounded-r-lg border-l-[3px] ${borderColor}` : 'rounded-lg',
202
+ activeSessionId === s.sessionId && 'bg-accent',
203
+ )}
204
+ >
205
+ <div className="flex items-start gap-2">
206
+ {slackIcon(s)}
207
+ <div className="min-w-0 flex-1">
208
+ <span className={cn('block truncate text-sm leading-snug', unread && 'font-semibold')}>
209
+ {s.title || 'New session'}
210
+ </span>
211
+ <div className="flex items-center gap-1.5 mt-0.5">
212
+ <span className="text-[10px] text-muted-foreground font-medium">
213
+ {slackLabel(s) || s.userName}
214
+ </span>
215
+ <span className="text-[10px] text-muted-foreground/50">·</span>
216
+ <span className="text-[10px] text-muted-foreground">{formatTime(s.lastModified)}</span>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ </button>
221
+ );
222
+ })}
223
+ </div>
224
+ </ScrollArea>
225
+
226
+ <div className="px-4 py-2 flex flex-col items-center gap-1">
227
+ <MachineStats socket={socket ?? null} getToken={getToken} />
228
+ {slots.sidebarExtras?.()}
229
+ {version && (
230
+ <div className="text-[10px] text-muted-foreground/50 text-center">v{version}</div>
231
+ )}
232
+ </div>
233
+ </div>
234
+ );
235
+ }
@@ -0,0 +1,280 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { BookOpen, Plus, Trash2, X, Check, Star, Copy, Pencil, Lock, ArrowLeft } from 'lucide-react';
3
+ import { Button } from './ui/button';
4
+ import { Input } from './ui/input';
5
+ import { Textarea } from './ui/textarea';
6
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
7
+
8
+ type DefaultSkillEntry = string | { name: string; capped?: boolean | number };
9
+
10
+ function entryName(e: DefaultSkillEntry): string {
11
+ return typeof e === 'string' ? e : e.name;
12
+ }
13
+ function isCapped(e: DefaultSkillEntry): boolean {
14
+ if (typeof e === 'string') return false;
15
+ return e.capped === true || (typeof e.capped === 'number' && e.capped > 0);
16
+ }
17
+ function isDefault(defaults: DefaultSkillEntry[], name: string): boolean {
18
+ return defaults.some(e => entryName(e) === name);
19
+ }
20
+ function findEntry(defaults: DefaultSkillEntry[], name: string): DefaultSkillEntry | undefined {
21
+ return defaults.find(e => entryName(e) === name);
22
+ }
23
+
24
+ interface Props {
25
+ getToken: () => Promise<string | null>;
26
+ onSkillsChange?: (skills: string[]) => void;
27
+ trigger?: React.ReactNode;
28
+ }
29
+
30
+ async function apiFetch(path: string, token: string, opts?: RequestInit) {
31
+ return fetch(path, { ...opts, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...(opts?.headers ?? {}) } });
32
+ }
33
+
34
+ type SidebarAction = null | 'create' | 'rename' | 'duplicate';
35
+
36
+ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
37
+ const [open, setOpen] = useState(false);
38
+ const [skills, setSkills] = useState<string[]>([]);
39
+ const [builtins, setBuiltins] = useState<string[]>([]);
40
+ const [defaults, setDefaults] = useState<DefaultSkillEntry[]>([]);
41
+ const [selected, setSelected] = useState<string | null>(null);
42
+ const [content, setContent] = useState('');
43
+ const [dirty, setDirty] = useState(false);
44
+ const [action, setAction] = useState<SidebarAction>(null);
45
+ const [actionName, setActionName] = useState('');
46
+
47
+ const isSelectedBuiltin = selected ? builtins.includes(selected) : false;
48
+
49
+ const loadList = async () => {
50
+ const token = await getToken();
51
+ if (!token) return;
52
+ const [skillsRes, defaultsRes] = await Promise.all([
53
+ apiFetch('/api/skills', token),
54
+ apiFetch('/api/skills-defaults', token),
55
+ ]);
56
+ const data: { skills: string[]; builtins: string[] } = await skillsRes.json();
57
+ const defs: DefaultSkillEntry[] = await defaultsRes.json();
58
+ setSkills(data.skills);
59
+ setBuiltins(data.builtins);
60
+ setDefaults(defs);
61
+ onSkillsChange?.(data.skills);
62
+ };
63
+
64
+ const loadSkill = async (name: string) => {
65
+ const token = await getToken();
66
+ if (!token) return;
67
+ const res = await apiFetch(`/api/skills/${name}`, token);
68
+ const skill = await res.json();
69
+ setContent(skill.content);
70
+ setSelected(name);
71
+ setDirty(false);
72
+ };
73
+
74
+ const save = async () => {
75
+ if (!selected || isSelectedBuiltin) return;
76
+ const token = await getToken();
77
+ if (!token) return;
78
+ await apiFetch(`/api/skills/${selected}`, token, { method: 'PUT', body: JSON.stringify({ content }) });
79
+ setDirty(false);
80
+ };
81
+
82
+ const submitAction = async () => {
83
+ const name = actionName.trim().replace(/\s+/g, '-');
84
+ if (!name) return;
85
+ const token = await getToken();
86
+ if (!token) return;
87
+
88
+ if (action === 'create') {
89
+ await apiFetch(`/api/skills/${name}`, token, { method: 'PUT', body: JSON.stringify({ content: '' }) });
90
+ await loadList();
91
+ await loadSkill(name);
92
+ } else if (action === 'duplicate' && selected) {
93
+ const res = await apiFetch(`/api/skills/${selected}/duplicate`, token, { method: 'POST', body: JSON.stringify({ newName: name }) });
94
+ if (!res.ok) return;
95
+ await loadList();
96
+ await loadSkill(name);
97
+ } else if (action === 'rename' && selected) {
98
+ const res = await apiFetch(`/api/skills/${selected}/rename`, token, { method: 'POST', body: JSON.stringify({ newName: name }) });
99
+ if (!res.ok) return;
100
+ await loadList();
101
+ await loadSkill(name);
102
+ }
103
+
104
+ setAction(null);
105
+ setActionName('');
106
+ };
107
+
108
+ const remove = async (name: string) => {
109
+ const token = await getToken();
110
+ if (!token) return;
111
+ const res = await apiFetch(`/api/skills/${name}`, token, { method: 'DELETE' });
112
+ if (!res.ok) return;
113
+ if (selected === name) { setSelected(null); setContent(''); }
114
+ await loadList();
115
+ };
116
+
117
+ const toggleDefault = async (name: string) => {
118
+ const token = await getToken();
119
+ if (!token) return;
120
+ const next = isDefault(defaults, name)
121
+ ? defaults.filter((d) => entryName(d) !== name)
122
+ : [...defaults, name];
123
+ await apiFetch('/api/skills-defaults', token, { method: 'PUT', body: JSON.stringify(next) });
124
+ setDefaults(next);
125
+ };
126
+
127
+
128
+ const startAction = (type: SidebarAction) => {
129
+ setAction(type);
130
+ setActionName(type === 'rename' && selected ? selected : type === 'duplicate' && selected ? `${selected}-copy` : '');
131
+ };
132
+
133
+ const cancelAction = () => { setAction(null); setActionName(''); };
134
+
135
+ useEffect(() => { if (open) loadList(); }, [open]);
136
+
137
+ const actionPlaceholder = action === 'create' ? 'skill-name' : action === 'rename' ? 'new-name' : 'copy-name';
138
+
139
+ return (
140
+ <Dialog open={open} onOpenChange={setOpen}>
141
+ <DialogTrigger asChild>
142
+ {trigger || (
143
+ <Button variant="ghost" size="icon" title="Skills" className="h-8 w-8">
144
+ <BookOpen className="w-4 h-4" />
145
+ </Button>
146
+ )}
147
+ </DialogTrigger>
148
+ <DialogContent className="max-w-[95vw] sm:max-w-2xl h-[70dvh] sm:h-[520px] flex flex-col gap-0 p-0">
149
+ <DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
150
+ <DialogTitle className="flex items-center gap-2">
151
+ <BookOpen className="w-4 h-4" /> Skills
152
+ </DialogTitle>
153
+ </DialogHeader>
154
+
155
+ <div className="flex flex-col sm:flex-row flex-1 overflow-hidden">
156
+ {/* Sidebar */}
157
+ <div className={`sm:w-52 border-r flex flex-col shrink-0 ${selected ? 'hidden sm:flex' : 'flex'}`}>
158
+ <div className="flex-1 overflow-y-auto py-1">
159
+ {skills.map((s) => (
160
+ <div
161
+ key={s}
162
+ className={`group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer text-sm hover:bg-accent transition-colors ${selected === s ? 'bg-accent font-medium' : ''}`}
163
+ onClick={() => loadSkill(s)}
164
+ >
165
+ <button
166
+ className={`shrink-0 transition-colors ${isDefault(defaults, s) ? 'text-amber-500' : 'text-muted-foreground/30 hover:text-amber-400'}`}
167
+ onClick={(e) => { e.stopPropagation(); toggleDefault(s); }}
168
+ title={isDefault(defaults, s) ? 'Remove from defaults' : 'Set as default (always active)'}
169
+ >
170
+ <Star className={`w-3 h-3 ${isDefault(defaults, s) ? 'fill-current' : ''}`} />
171
+ </button>
172
+ <span className="truncate flex-1">@{s}</span>
173
+ {builtins.includes(s) && (
174
+ <span title="Built-in (read-only)"><Lock className="w-2.5 h-2.5 text-muted-foreground/40 shrink-0" /></span>
175
+ )}
176
+ {!builtins.includes(s) && (
177
+ <button
178
+ className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-opacity shrink-0"
179
+ onClick={(e) => { e.stopPropagation(); remove(s); }}
180
+ title="Delete"
181
+ >
182
+ <Trash2 className="w-3 h-3" />
183
+ </button>
184
+ )}
185
+ </div>
186
+ ))}
187
+ </div>
188
+
189
+ <div className="border-t p-2">
190
+ {action ? (
191
+ <div className="flex gap-1">
192
+ <Input
193
+ value={actionName}
194
+ onChange={(e) => setActionName(e.target.value)}
195
+ onKeyDown={(e) => { if (e.key === 'Enter') submitAction(); if (e.key === 'Escape') cancelAction(); }}
196
+ placeholder={actionPlaceholder}
197
+ className="h-7 text-xs"
198
+ autoFocus
199
+ />
200
+ <Button size="icon" className="h-7 w-7 shrink-0" onClick={submitAction}><Check className="w-3 h-3" /></Button>
201
+ <Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={cancelAction}><X className="w-3 h-3" /></Button>
202
+ </div>
203
+ ) : (
204
+ <Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={() => startAction('create')}>
205
+ <Plus className="w-3 h-3 mr-1" /> New skill
206
+ </Button>
207
+ )}
208
+ </div>
209
+ </div>
210
+
211
+ {/* Editor */}
212
+ <div className={`flex-1 flex flex-col overflow-hidden ${!selected ? 'hidden sm:flex' : ''}`}>
213
+ {selected ? (
214
+ <>
215
+ <div className="flex items-center justify-between px-3 py-2 border-b text-sm shrink-0">
216
+ <div className="flex items-center gap-0.5 font-medium">
217
+ <button className="sm:hidden mr-1.5 text-muted-foreground" onClick={() => { setSelected(null); setContent(''); setDirty(false); }}><ArrowLeft className="w-4 h-4" /></button>
218
+ <span className="text-muted-foreground">@</span>
219
+ <span>{selected}</span>
220
+ </div>
221
+ <div className="flex items-center gap-1.5">
222
+ {isSelectedBuiltin && (
223
+ <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-medium flex items-center gap-0.5">
224
+ <Lock className="w-2.5 h-2.5" /> read-only
225
+ </span>
226
+ )}
227
+ {isDefault(defaults, selected) && (
228
+ <>
229
+ <span className="text-[10px] text-amber-600 bg-amber-50 dark:bg-amber-950/50 dark:text-amber-400 px-1.5 py-0.5 rounded font-medium">
230
+ default
231
+ </span>
232
+ <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${isCapped(findEntry(defaults, selected)!) ? 'text-blue-600 bg-blue-50 dark:bg-blue-950/50 dark:text-blue-400' : 'text-emerald-600 bg-emerald-50 dark:bg-emerald-950/50 dark:text-emerald-400'}`}>
233
+ {isCapped(findEntry(defaults, selected)!) ? 'capped' : 'full'}
234
+ </span>
235
+ </>
236
+ )}
237
+ <Button
238
+ variant="ghost" size="sm" className="h-6 text-xs px-1.5"
239
+ onClick={() => startAction('duplicate')}
240
+ title="Duplicate"
241
+ >
242
+ <Copy className="w-3 h-3" />
243
+ </Button>
244
+ {!isSelectedBuiltin && (
245
+ <Button
246
+ variant="ghost" size="sm" className="h-6 text-xs px-1.5"
247
+ onClick={() => startAction('rename')}
248
+ title="Rename"
249
+ >
250
+ <Pencil className="w-3 h-3" />
251
+ </Button>
252
+ )}
253
+ {dirty && !isSelectedBuiltin && (
254
+ <Button size="sm" className="h-6 text-xs" onClick={save}>
255
+ <Check className="w-3 h-3 mr-1" /> Save
256
+ </Button>
257
+ )}
258
+ </div>
259
+ </div>
260
+ <Textarea
261
+ value={content}
262
+ onChange={(e) => { if (!isSelectedBuiltin) { setContent(e.target.value); setDirty(true); } }}
263
+ readOnly={isSelectedBuiltin}
264
+ className={`flex-1 resize-none rounded-none border-0 font-mono text-xs focus-visible:ring-0 ${isSelectedBuiltin ? 'opacity-70 cursor-default' : ''}`}
265
+ placeholder="Write the skill instructions here…"
266
+ />
267
+ </>
268
+ ) : (
269
+ <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground text-sm gap-1">
270
+ <p>Select or create a skill</p>
271
+ <p className="text-xs"><Star className="w-3 h-3 inline fill-amber-500 text-amber-500" /> = injected in every conversation</p>
272
+ <p className="text-xs"><Lock className="w-3 h-3 inline" /> = built-in, duplicate to customize</p>
273
+ </div>
274
+ )}
275
+ </div>
276
+ </div>
277
+ </DialogContent>
278
+ </Dialog>
279
+ );
280
+ }
@@ -0,0 +1,167 @@
1
+ import { useMemo } from 'react';
2
+ import {
3
+ ResponsiveContainer, BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
4
+ XAxis, YAxis, CartesianGrid, Tooltip, Legend,
5
+ } from 'recharts';
6
+
7
+ const COLORS = ['#6366f1', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
8
+
9
+ interface Props {
10
+ data: unknown;
11
+ }
12
+
13
+ type Row = Record<string, unknown>;
14
+
15
+ /** Try to parse raw string as JSON, return null on failure */
16
+ export function tryParseChartData(raw: string): unknown | null {
17
+ const trimmed = raw.trim();
18
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null;
19
+ try { return JSON.parse(trimmed); } catch { return null; }
20
+ }
21
+
22
+ /** Flatten nested Mixpanel-style { data: { values: { series: { date: val } } } } into rows */
23
+ function flattenMixpanelValues(obj: Record<string, unknown>): Row[] | null {
24
+ const values = (obj.data as any)?.values ?? obj.values ?? obj.series;
25
+ if (!values || typeof values !== 'object') return null;
26
+
27
+ const seriesMap = values as Record<string, Record<string, number>>;
28
+ const seriesNames = Object.keys(seriesMap);
29
+ if (seriesNames.length === 0) return null;
30
+
31
+ const firstSeries = seriesMap[seriesNames[0]];
32
+ if (!firstSeries || typeof firstSeries !== 'object') return null;
33
+
34
+ const labels = Object.keys(firstSeries);
35
+ return labels.map((label) => {
36
+ const row: Row = { label };
37
+ for (const name of seriesNames) row[name] = seriesMap[name]?.[label] ?? 0;
38
+ return row;
39
+ });
40
+ }
41
+
42
+ /** Detect MCP spool metadata: { file, type|count|childCount, sizeBytes, preview } */
43
+ function isSpoolMeta(data: unknown): data is Record<string, unknown> {
44
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
45
+ const obj = data as Record<string, unknown>;
46
+ return 'file' in obj && 'sizeBytes' in obj;
47
+ }
48
+
49
+ /** Normalize any JSON shape into an array of flat row objects */
50
+ function normalizeToRows(data: unknown): Row[] | null {
51
+ if (isSpoolMeta(data)) return null;
52
+ if (Array.isArray(data)) {
53
+ if (data.length === 0) return null;
54
+ if (typeof data[0] === 'object' && data[0] !== null) return data as Row[];
55
+ return null;
56
+ }
57
+ if (typeof data === 'object' && data !== null) {
58
+ const nested = flattenMixpanelValues(data as Record<string, unknown>);
59
+ if (nested) return nested;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ type ChartType = 'bar' | 'line' | 'pie' | 'table';
65
+
66
+ function detectChartType(rows: Row[], labelKey: string, numericKeys: string[]): ChartType {
67
+ if (numericKeys.length === 0) return 'table';
68
+ if (numericKeys.length === 1 && rows.length <= 8) return 'pie';
69
+ const labels = rows.map((r) => String(r[labelKey] ?? ''));
70
+ const looksTimeSeries = labels.some((l) => /^\d{4}-\d{2}/.test(l));
71
+ if (looksTimeSeries) return 'line';
72
+ return rows.length > 20 ? 'line' : 'bar';
73
+ }
74
+
75
+ function classifyKeys(rows: Row[]) {
76
+ const allKeys = [...new Set(rows.flatMap(Object.keys))];
77
+ const numericKeys: string[] = [];
78
+ let labelKey = '';
79
+
80
+ for (const key of allKeys) {
81
+ const vals = rows.map((r) => r[key]).filter((v) => v !== null && v !== undefined);
82
+ const numCount = vals.filter((v) => typeof v === 'number').length;
83
+ if (numCount > vals.length * 0.5) numericKeys.push(key);
84
+ else if (!labelKey) labelKey = key;
85
+ }
86
+ if (!labelKey) labelKey = allKeys[0] ?? 'label';
87
+ return { labelKey, numericKeys };
88
+ }
89
+
90
+ export function SmartChart({ data }: Props) {
91
+ const { rows, labelKey, numericKeys, chartType } = useMemo(() => {
92
+ const rows = normalizeToRows(data);
93
+ if (!rows || rows.length === 0) return { rows: null, labelKey: '', numericKeys: [] as string[], chartType: 'table' as ChartType };
94
+ const { labelKey, numericKeys } = classifyKeys(rows);
95
+ const chartType = detectChartType(rows, labelKey, numericKeys);
96
+ return { rows, labelKey, numericKeys, chartType };
97
+ }, [data]);
98
+
99
+ if (!rows) return <pre className="text-xs whitespace-pre-wrap break-words">{JSON.stringify(data, null, 2)}</pre>;
100
+
101
+ if (chartType === 'table') return <DataTable rows={rows} />;
102
+
103
+ return (
104
+ <div className="space-y-2">
105
+ <div className="h-64 w-full">
106
+ <ResponsiveContainer width="100%" height="100%">
107
+ {chartType === 'pie' ? (
108
+ <PieChart>
109
+ <Pie data={rows} dataKey={numericKeys[0]} nameKey={labelKey} cx="50%" cy="50%" outerRadius={80} label>
110
+ {rows.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
111
+ </Pie>
112
+ <Tooltip />
113
+ <Legend />
114
+ </PieChart>
115
+ ) : chartType === 'line' ? (
116
+ <LineChart data={rows}>
117
+ <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
118
+ <XAxis dataKey={labelKey} tick={{ fontSize: 11 }} />
119
+ <YAxis tick={{ fontSize: 11 }} />
120
+ <Tooltip />
121
+ {numericKeys.length > 1 && <Legend />}
122
+ {numericKeys.map((key, i) => (
123
+ <Line key={key} type="monotone" dataKey={key} stroke={COLORS[i % COLORS.length]} strokeWidth={2} dot={false} />
124
+ ))}
125
+ </LineChart>
126
+ ) : (
127
+ <BarChart data={rows}>
128
+ <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
129
+ <XAxis dataKey={labelKey} tick={{ fontSize: 11 }} />
130
+ <YAxis tick={{ fontSize: 11 }} />
131
+ <Tooltip />
132
+ {numericKeys.length > 1 && <Legend />}
133
+ {numericKeys.map((key, i) => (
134
+ <Bar key={key} dataKey={key} fill={COLORS[i % COLORS.length]} radius={[3, 3, 0, 0]} />
135
+ ))}
136
+ </BarChart>
137
+ )}
138
+ </ResponsiveContainer>
139
+ </div>
140
+ <DataTable rows={rows} />
141
+ </div>
142
+ );
143
+ }
144
+
145
+ function DataTable({ rows }: { rows: Row[] }) {
146
+ const keys = [...new Set(rows.flatMap(Object.keys))];
147
+ return (
148
+ <div className="overflow-auto max-h-96 rounded border text-xs">
149
+ <table className="w-full text-left">
150
+ <thead className="bg-muted sticky top-0">
151
+ <tr>{keys.map((k) => <th key={k} className="px-2 py-1 font-medium">{k}</th>)}</tr>
152
+ </thead>
153
+ <tbody>
154
+ {rows.map((row, i) => (
155
+ <tr key={i} className="border-t">
156
+ {keys.map((k) => {
157
+ const v = row[k];
158
+ const cell = v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v);
159
+ return <td key={k} className="px-2 py-1 max-w-[400px] whitespace-pre-wrap break-words align-top" title={cell}>{cell}</td>;
160
+ })}
161
+ </tr>
162
+ ))}
163
+ </tbody>
164
+ </table>
165
+ </div>
166
+ );
167
+ }