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,394 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { summarizeText } from './summarize.ts';
3
+ import { dataSync } from './data-sync.ts';
4
+ import type { McpConfig } from './mcp.ts';
5
+ import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, type ConvMessage, type ConvBlock } from './sessions.ts';
6
+ import {
7
+ resolveDefaultSkillsContent,
8
+ expandMentionedSkills,
9
+ buildMcpSkillHintsBlock,
10
+ buildSkillIndexBlock,
11
+ matchTriggeredSkillNames,
12
+ skillInjectionBlocks,
13
+ getSkill,
14
+ getMcpCommandPrompt,
15
+ parseSkillFrontmatter,
16
+ } from './skills.ts';
17
+
18
+ import { buildWorkspaceContextBlock, expandWorkspaceMentions } from './workspace.ts';
19
+ import { parseDirectives, type Directives } from './directives.ts';
20
+ import { parseSlashCommand, formatCommandBlock } from './commands.ts';
21
+ import { getUserContextBlock } from './user-context.ts';
22
+ import { collectTurnContext } from './turn-context.ts';
23
+ import { DATA_DIR, dataPath } from './paths.ts';
24
+ import * as contacts from './contacts.ts';
25
+ import { resolveAndGetEngine, resolveEngine } from './engine/index.ts';
26
+
27
+ const CONFIG_PATH = dataPath('agent-config.json');
28
+
29
+ // ── Agent config (shared across users) ──────────────────────────────────────
30
+
31
+ export type { AgentSettings as AgentConfig } from './shraga-config.ts';
32
+ import type { AgentSettings as AgentConfig } from './shraga-config.ts';
33
+
34
+ const DEFAULT_CONFIG: AgentConfig = {
35
+ /** ToolSearch loads deferred MCP tools; without it, permission prompts / tool graph can block Meta Ads tools. */
36
+ allowedTools: ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'],
37
+ permissionMode: 'acceptEdits',
38
+ maxTurns: 15,
39
+ // Defaults are what a fresh self-hosted install runs before anyone touches the UI, so they favour
40
+ // cost/latency over ceiling. Both are overridable per-deployment via agent-config.json and per-send
41
+ // via directives — an operator who wants a bigger model sets it once; every operator pays for a default.
42
+ model: 'claude-sonnet-5',
43
+ effort: 'low',
44
+ };
45
+
46
+ export function getAgentConfig(): AgentConfig {
47
+ // agent-config.json (UI-writable, git-tracked) is the single source of truth for agent settings.
48
+ let config = { ...DEFAULT_CONFIG };
49
+ if (existsSync(CONFIG_PATH)) {
50
+ try { Object.assign(config, JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))); } catch (e) { console.warn('[claude] failed to parse agent-config.json:', e); }
51
+ }
52
+ return config;
53
+ }
54
+
55
+ export function saveAgentConfig(config: AgentConfig): void {
56
+ mkdirSync(DATA_DIR, { recursive: true });
57
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
58
+ dataSync.trackWrite('agent-config.json');
59
+ }
60
+
61
+ // ── WS events ───────────────────────────────────────────────────────────────
62
+
63
+ export type WsEvent =
64
+ | { type: 'text_delta'; text: string }
65
+ | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
66
+ | { type: 'tool_use_input'; toolUseId: string; input: unknown }
67
+ | { type: 'tool_result'; toolUseId: string; output: string }
68
+ | { type: 'tool_result_image'; toolUseId: string; dataUrl: string }
69
+ | { type: 'permission_request'; id: string; tool: string; input: Record<string, unknown> }
70
+ | { type: 'question_request'; id: string; questions: AskQuestion[] }
71
+ | { type: 'thinking_delta'; text: string }
72
+ | { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}); builtinHandled?: boolean }
73
+ | { type: 'model_resolved'; sessionId: string; model: string }
74
+ | { type: 'duplex_task'; taskId: string; status: 'started' | 'progress' | 'done' | 'error' | 'cancelled' | 'ask'; label?: string; text?: string; tier?: string }
75
+ | { type: 'duplex_revoice'; text: string }
76
+ | { type: 'duplex_result'; taskId: string; text: string; label?: string; tier?: string }
77
+ | { type: 'error'; message: string }
78
+ | { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number } };
79
+
80
+ // ── Stream chat ─────────────────────────────────────────────────────────────
81
+
82
+ const COMPACT_THRESHOLD = 30;
83
+ const RECENT_KEEP = 10;
84
+
85
+ export type PermissionHandler = (id: string, tool: string, input: Record<string, unknown>) => Promise<{ allow: boolean }>;
86
+
87
+ /** A single question the agent posed via the built-in AskUserQuestion tool. */
88
+ export type AskQuestion = {
89
+ question: string;
90
+ header: string;
91
+ multiSelect?: boolean;
92
+ options: { label: string; description: string; preview?: string }[];
93
+ };
94
+ /** Answers keyed by question text. String for single-select, string[] for multiSelect. */
95
+ export type QuestionAnswers = Record<string, string | string[]>;
96
+ /**
97
+ * Medium-agnostic handler for AskUserQuestion. Present the questions to a human
98
+ * (web UI, Slack, email, …), collect answers, and return them — they become the
99
+ * tool result. Return null if no human is reachable / dismissed / timed out, in
100
+ * which case the agent proceeds with its own best judgement.
101
+ */
102
+ export type QuestionHandler = (id: string, questions: AskQuestion[]) => Promise<QuestionAnswers | null>;
103
+
104
+ function messagesSinceSummary(conv: ConvMessage[]): number {
105
+ const summaryIdx = conv.findLastIndex((m) =>
106
+ m.blocks.some((b) => b.type === 'summary')
107
+ );
108
+ return summaryIdx >= 0 ? conv.length - summaryIdx - 1 : conv.length;
109
+ }
110
+
111
+ function messagesToText(messages: ConvMessage[]): string {
112
+ return messages.map((m) => {
113
+ const role = m.role === 'user' ? 'User' : 'Assistant';
114
+ const texts = m.blocks
115
+ .filter((b) => b.type === 'text')
116
+ .map((b) => (b as { type: 'text'; text: string }).text)
117
+ .filter(Boolean);
118
+ return texts.length ? `${role}: ${texts.join('\n')}` : '';
119
+ }).filter(Boolean).join('\n\n');
120
+ }
121
+
122
+ async function maybeCompact(sessionId: string, conv: ConvMessage[]): Promise<void> {
123
+ const sinceLast = messagesSinceSummary(conv);
124
+ if (sinceLast < COMPACT_THRESHOLD) return;
125
+
126
+ const summaryIdx = conv.findLastIndex((m) =>
127
+ m.blocks.some((b) => b.type === 'summary')
128
+ );
129
+ const startIdx = summaryIdx >= 0 ? summaryIdx + 1 : 0;
130
+ const toCompact = conv.slice(startIdx, conv.length - RECENT_KEEP);
131
+ if (toCompact.length < RECENT_KEEP) return;
132
+
133
+ const existingSummary = summaryIdx >= 0
134
+ ? (conv[summaryIdx].blocks.find((b) => b.type === 'summary') as { type: 'summary'; text: string }).text
135
+ : null;
136
+
137
+ const instruction = existingSummary
138
+ ? `Here is an existing conversation summary:\n<existing_summary>\n${existingSummary}\n</existing_summary>\n\nHere are the new messages since that summary. Create an updated, comprehensive summary of the entire conversation so far. Be concise but preserve key facts, decisions, and context. Focus on what matters for continuing the conversation.`
139
+ : `Summarize this conversation concisely. Preserve key facts, decisions, open questions, and context needed to continue the conversation:`;
140
+
141
+ try {
142
+ const summaryText = await summarizeText(messagesToText(toCompact), instruction);
143
+ if (!summaryText) return;
144
+
145
+ const totalCompacted = (summaryIdx >= 0
146
+ ? (conv[summaryIdx].blocks.find((b) => b.type === 'summary') as { type: 'summary'; text: string; compactedCount: number }).compactedCount
147
+ : 0) + toCompact.length;
148
+
149
+ const summaryMsg: ConvMessage = {
150
+ id: crypto.randomUUID(),
151
+ role: 'assistant',
152
+ blocks: [{ type: 'summary', text: summaryText, compactedCount: totalCompacted }],
153
+ };
154
+
155
+ const kept = conv.slice(conv.length - RECENT_KEEP);
156
+ const newConv = summaryIdx >= 0
157
+ ? [...conv.slice(0, summaryIdx), summaryMsg, ...kept]
158
+ : [summaryMsg, ...kept];
159
+
160
+ saveConversation(sessionId, newConv);
161
+ conv.length = 0;
162
+ conv.push(...newConv);
163
+ console.log(`[claude] Compacted ${toCompact.length} messages for ${sessionId.slice(0, 8)} (total compacted: ${totalCompacted})`);
164
+ } catch (err) {
165
+ console.error('[claude] Compaction failed:', err);
166
+ }
167
+ }
168
+
169
+ function applyCompactMarkers(conv: ConvMessage[]): ConvMessage[] {
170
+ const markerIdx = conv.findLastIndex((m) =>
171
+ m.blocks.some((b) => b.type === 'compact_marker')
172
+ );
173
+ if (markerIdx < 0) return conv;
174
+ const marker = conv[markerIdx].blocks.find((b) => b.type === 'compact_marker') as { summary: string };
175
+ const summaryMsg: ConvMessage = {
176
+ id: 'compact-summary',
177
+ role: 'assistant',
178
+ blocks: [{ type: 'text', text: `<conversation_summary>\n${marker.summary}\n</conversation_summary>` }],
179
+ };
180
+ return [summaryMsg, ...conv.slice(markerIdx + 1)];
181
+ }
182
+
183
+ export interface AttachmentMeta { url: string; name: string; mimeType: string; path: string }
184
+
185
+ export async function* streamChat(opts: {
186
+ prompt: string;
187
+ attachments?: AttachmentMeta[];
188
+ images?: string[];
189
+ sessionId?: string;
190
+ uid: string;
191
+ userEmail?: string;
192
+ userName?: string;
193
+ mcpServers?: McpConfig;
194
+ abortController?: AbortController;
195
+ onPermissionRequest?: PermissionHandler;
196
+ onDestructiveApproval?: PermissionHandler;
197
+ onUserQuestion?: QuestionHandler;
198
+ voiceMode?: boolean;
199
+ conversationReset?: boolean;
200
+ /** Opaque per-send bag from the client. Never interpreted here — handed to the turn-context seam,
201
+ * where an add-on's contributor reads its own keys. */
202
+ turnHints?: Record<string, unknown>;
203
+ context?: Record<string, string>;
204
+ }): AsyncGenerator<WsEvent> {
205
+ const config = getAgentConfig();
206
+ const { prompt: cleanPrompt, directives: parsed } = parseDirectives(opts.prompt);
207
+
208
+ const sessionMeta = opts.sessionId ? getSession(opts.sessionId) : undefined;
209
+ const directives: Directives = { ...sessionMeta?.directives, ...parsed };
210
+ // Pin the resolved runtime shape on the session so reopening it later resumes the
211
+ // exact same engine/model even if global defaults change. Gaps only — stored or
212
+ // inline directives always win.
213
+ if (!directives.engine) directives.engine = resolveEngine(parsed, config);
214
+ if (!directives.model && config.model) directives.model = config.model;
215
+ if (!directives.turns && config.maxTurns) directives.turns = config.maxTurns;
216
+ if (!directives.thinking && config.thinking) directives.thinking = config.thinking;
217
+ if (opts.sessionId && JSON.stringify(directives) !== JSON.stringify(sessionMeta?.directives ?? {})) {
218
+ setSessionDirectives(opts.sessionId, directives);
219
+ console.log(`[claude] Directives: ${JSON.stringify(directives)}`);
220
+ yield { type: 'directives', directives } as any;
221
+ }
222
+
223
+ // Built-in /compact command
224
+ const slashCmd = parseSlashCommand(cleanPrompt);
225
+ if (slashCmd?.command === 'compact') {
226
+ console.log('[claude] /compact intercepted — handling as built-in');
227
+ const sessionId = opts.sessionId;
228
+ if (!sessionId) {
229
+ yield { type: 'text_delta', text: 'Nothing to compact — no active session.' };
230
+ yield { type: 'done', sessionId: '', builtinHandled: true } as any;
231
+ return;
232
+ }
233
+ const conv = loadConversation(sessionId);
234
+ const lastMarkerIdx = conv.findLastIndex((m) => m.blocks.some((b) => b.type === 'compact_marker'));
235
+ const toSummarize = conv.slice(lastMarkerIdx >= 0 ? lastMarkerIdx + 1 : 0);
236
+ if (toSummarize.length < 4) {
237
+ yield { type: 'text_delta', text: 'Conversation too short to compact.' };
238
+ yield { type: 'done', sessionId, builtinHandled: true } as any;
239
+ return;
240
+ }
241
+ try {
242
+ const existingSummary = lastMarkerIdx >= 0
243
+ ? (conv[lastMarkerIdx].blocks.find((b) => b.type === 'compact_marker') as { summary: string }).summary
244
+ : null;
245
+ const instruction = existingSummary
246
+ ? `Here is an existing conversation summary:\n<existing_summary>\n${existingSummary}\n</existing_summary>\n\nHere are the new messages since that summary. Create an updated, comprehensive summary of the entire conversation so far. Be concise but preserve key facts, decisions, and context. Output only the summary — no preamble, no questions, no conversational filler.`
247
+ : `Summarize this conversation concisely. Preserve key facts, decisions, open questions, and context needed to continue the conversation. Output only the summary — no preamble, no questions, no conversational filler.`;
248
+ const summaryText = await summarizeText(messagesToText(toSummarize), instruction);
249
+ if (!summaryText) {
250
+ yield { type: 'text_delta', text: 'Failed to generate summary.' };
251
+ yield { type: 'done', sessionId, builtinHandled: true } as any;
252
+ return;
253
+ }
254
+ const prevCompacted = lastMarkerIdx >= 0
255
+ ? (conv[lastMarkerIdx].blocks.find((b) => b.type === 'compact_marker') as { compactedCount: number }).compactedCount
256
+ : 0;
257
+ const compactedCount = prevCompacted + toSummarize.length;
258
+ appendMessage(sessionId, {
259
+ id: crypto.randomUUID(),
260
+ role: 'assistant',
261
+ blocks: [{ type: 'compact_marker', summary: summaryText, compactedCount }],
262
+ });
263
+ yield { type: 'compact_marker', summary: summaryText, compactedCount, sessionId } as any;
264
+ } catch (err) {
265
+ console.error('[claude] /compact failed:', err);
266
+ yield { type: 'text_delta', text: 'Compaction failed.' };
267
+ }
268
+ yield { type: 'done', sessionId, builtinHandled: true } as any;
269
+ return;
270
+ }
271
+
272
+ // Slash command resolution
273
+ let effectivePrompt = cleanPrompt;
274
+ if (slashCmd) {
275
+ const skill = getSkill(slashCmd.command);
276
+ if (skill) {
277
+ const { body } = parseSkillFrontmatter(skill.content);
278
+ effectivePrompt = formatCommandBlock(slashCmd.command, body, slashCmd.args);
279
+ if (skill.meta.model) directives.model = skill.meta.model;
280
+ } else if (opts.mcpServers && slashCmd.command in opts.mcpServers) {
281
+ effectivePrompt = getMcpCommandPrompt(slashCmd.command, slashCmd.args);
282
+ } else {
283
+ yield { type: 'text_delta', text: `Unknown command: /${slashCmd.command}` };
284
+ yield { type: 'done', sessionId: opts.sessionId ?? '' };
285
+ return;
286
+ }
287
+ }
288
+
289
+ // Expand @skill + @workspace-file mentions
290
+ const withSkillMentions = expandMentionedSkills(effectivePrompt);
291
+ const withMentions = expandWorkspaceMentions(withSkillMentions);
292
+ const defaultSkills = resolveDefaultSkillsContent();
293
+ const mcpSkills = opts.mcpServers ? buildMcpSkillHintsBlock(Object.keys(opts.mcpServers)) : '';
294
+ const discoveryEnabled = config.skillDiscovery !== false;
295
+ const skillIndex = discoveryEnabled ? buildSkillIndexBlock() : '';
296
+ // Triggered skills are sticky: once matched in a session, they stay injected on every later turn.
297
+ const stickyNames = opts.sessionId ? getSession(opts.sessionId)?.triggeredSkills ?? [] : [];
298
+ const newTriggerNames = discoveryEnabled ? matchTriggeredSkillNames(effectivePrompt, opts.context) : [];
299
+ const triggeredNames = [...new Set([...stickyNames, ...newTriggerNames])];
300
+ const triggeredSkills = skillInjectionBlocks(triggeredNames);
301
+ const workspaceTree = buildWorkspaceContextBlock();
302
+ const contact = opts.userEmail ? contacts.find({ email: opts.userEmail }) : null;
303
+ const userBlock = contacts.formatUserBlock(contact);
304
+ const teamRoster = contacts.formatRoster();
305
+ const userContextBlock = getUserContextBlock(contact);
306
+ const contextBlock = [userBlock, userContextBlock, teamRoster, defaultSkills, triggeredSkills, skillIndex, mcpSkills, workspaceTree].filter(Boolean).join('\n');
307
+
308
+ // Load conversation for the engine
309
+ const sessionId = opts.sessionId ?? crypto.randomUUID();
310
+ if (newTriggerNames.length) {
311
+ // Some channels (WS) upsert the session only after streaming — ensure the record exists first.
312
+ // email may be absent on this path (userEmail is optional); keep the value as-is —
313
+ // `as string` asserts the record contract without altering the stored value.
314
+ upsertSession(sessionId, effectivePrompt, { uid: opts.uid, email: opts.userEmail as string, name: opts.userName });
315
+ addTriggeredSkills(sessionId, newTriggerNames);
316
+ }
317
+ let conversation: ConvMessage[] = [];
318
+ if (opts.sessionId) {
319
+ conversation = loadConversation(opts.sessionId);
320
+ if (conversation.length > 0) await maybeCompact(opts.sessionId, conversation);
321
+ conversation = applyCompactMarkers(conversation);
322
+ }
323
+
324
+ // Per-turn add-on context: prepend whatever the turn-context seam's contributors return to THIS
325
+ // turn's prompt only (not the cacheable contextBlock, not the persisted user message). The core
326
+ // registers no contributors, so this is a no-op here and the prompt is unchanged.
327
+ let turnPrompt = withMentions;
328
+ const turnContext = collectTurnContext({ sessionId, uid: opts.uid, hints: opts.turnHints });
329
+ if (turnContext) {
330
+ turnPrompt = `${turnContext}\n\n${withMentions}`;
331
+ console.log(`[stream] turn-context injected (${turnContext.length} chars) for session=${sessionId}`);
332
+ }
333
+
334
+ // Resolve engine and delegate
335
+ const engine = resolveAndGetEngine(directives as any, config);
336
+ console.log(`[stream] engine=${engine.name} user=${opts.uid} session=${sessionId}`);
337
+
338
+ yield* engine.stream({
339
+ prompt: turnPrompt,
340
+ conversation,
341
+ contextBlock,
342
+ attachments: opts.attachments,
343
+ images: opts.images,
344
+ sessionId,
345
+ uid: opts.uid,
346
+ userEmail: opts.userEmail,
347
+ userName: opts.userName,
348
+ mcpServers: opts.mcpServers,
349
+ abortController: opts.abortController,
350
+ onPermissionRequest: opts.onPermissionRequest,
351
+ onDestructiveApproval: opts.onDestructiveApproval,
352
+ onUserQuestion: opts.onUserQuestion,
353
+ voiceMode: opts.voiceMode,
354
+ conversationReset: opts.conversationReset,
355
+ context: opts.context,
356
+ directives,
357
+ config,
358
+ });
359
+ }
360
+
361
+ export async function consumeStream(stream: AsyncGenerator<WsEvent>, onEvent?: (ev: WsEvent) => void): Promise<ConvBlock[]> {
362
+ let text = '';
363
+ let thinking = '';
364
+ const blocks: ConvBlock[] = [];
365
+ for await (const ev of stream) {
366
+ onEvent?.(ev);
367
+ if (ev.type === 'thinking_delta') {
368
+ thinking += ev.text;
369
+ } else if (ev.type === 'text_delta') {
370
+ if (thinking) { blocks.push({ type: 'thinking', text: thinking }); thinking = ''; }
371
+ text += ev.text;
372
+ } else if (ev.type === 'tool_use') {
373
+ if (thinking) { blocks.push({ type: 'thinking', text: thinking }); thinking = ''; }
374
+ if (text) { blocks.push({ type: 'text', text }); text = ''; }
375
+ blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
376
+ } else if (ev.type === 'tool_use_input') {
377
+ const existing = blocks.find((b) => b.type === 'tool_use' && b.toolUseId === ev.toolUseId) as any;
378
+ if (existing) existing.input = ev.input;
379
+ } else if (ev.type === 'tool_result') {
380
+ blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
381
+ } else if (ev.type === 'tool_result_image') {
382
+ blocks.push({ type: 'image', src: ev.dataUrl });
383
+ } else if (ev.type === 'done') {
384
+ break;
385
+ } else if (ev.type === 'error') {
386
+ text += `\n⚠️ ${ev.message}`;
387
+ break;
388
+ }
389
+ }
390
+ if (thinking) blocks.push({ type: 'thinking', text: thinking });
391
+ if (text) blocks.push({ type: 'text', text });
392
+ return blocks;
393
+ }
394
+
@@ -0,0 +1,21 @@
1
+ export interface SlashCommand {
2
+ command: string;
3
+ args: string;
4
+ }
5
+
6
+ const SLASH_RE = /^\/(\w[\w-]*)(?:\s+([\s\S]*))?$/;
7
+
8
+ export function parseSlashCommand(text: string): SlashCommand | null {
9
+ const m = text.match(SLASH_RE);
10
+ if (!m) return null;
11
+ return { command: m[1], args: (m[2] ?? '').trim() };
12
+ }
13
+
14
+ export function formatCommandBlock(name: string, content: string, args: string): string {
15
+ const body = content.includes('$ARGUMENTS')
16
+ ? content.replace(/\$ARGUMENTS/g, args || '')
17
+ : args
18
+ ? `${content}\n\nARGUMENTS: ${args}`
19
+ : content;
20
+ return `<command name="${name}">\n${body}\n</command>`;
21
+ }
@@ -0,0 +1,177 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dataPath } from './paths.ts';
3
+ import { dataSync } from './data-sync.ts';
4
+
5
+ export interface Contact {
6
+ id: string;
7
+ name: string;
8
+ emails: string[];
9
+ slackIds: string[];
10
+ role?: string;
11
+ isOperator: boolean;
12
+ isOwner?: boolean;
13
+ firstSeen: number;
14
+ lastSeen: number;
15
+ }
16
+
17
+ const CONTACTS_PATH = dataPath('contacts.json');
18
+ let contacts: Contact[] = [];
19
+ let byEmail = new Map<string, Contact>();
20
+ let bySlackId = new Map<string, Contact>();
21
+ let dirty = false;
22
+
23
+ function rebuildIndexes() {
24
+ byEmail = new Map();
25
+ bySlackId = new Map();
26
+ for (const c of contacts) {
27
+ for (const e of c.emails) byEmail.set(e.toLowerCase(), c);
28
+ for (const s of c.slackIds) bySlackId.set(s, c);
29
+ }
30
+ }
31
+
32
+ function load() {
33
+ if (!existsSync(CONTACTS_PATH)) { contacts = []; rebuildIndexes(); return; }
34
+ try {
35
+ contacts = JSON.parse(readFileSync(CONTACTS_PATH, 'utf-8'));
36
+ } catch (err) { console.error('[contacts] Failed to load contacts.json:', (err as Error).message); contacts = []; }
37
+ rebuildIndexes();
38
+ }
39
+
40
+ function save() {
41
+ if (!dirty) return;
42
+ writeFileSync(CONTACTS_PATH, JSON.stringify(contacts, null, 2));
43
+ dirty = false;
44
+ dataSync.trackWrite('contacts.json');
45
+ }
46
+
47
+ load();
48
+
49
+ export function find(opts: { email?: string; slackId?: string }): Contact | null {
50
+ if (opts.email) { const c = byEmail.get(opts.email.toLowerCase()); if (c) return c; }
51
+ if (opts.slackId) { const c = bySlackId.get(opts.slackId); if (c) return c; }
52
+ return null;
53
+ }
54
+
55
+ function mergeContacts(a: Contact, b: Contact): Contact {
56
+ const merged: Contact = {
57
+ id: a.firstSeen <= b.firstSeen ? a.id : b.id,
58
+ name: b.lastSeen >= a.lastSeen ? (b.name || a.name) : (a.name || b.name),
59
+ emails: [...new Set([...a.emails, ...b.emails])],
60
+ slackIds: [...new Set([...a.slackIds, ...b.slackIds])],
61
+ role: (b.lastSeen >= a.lastSeen ? b.role : a.role) || a.role || b.role,
62
+ isOperator: a.isOperator || b.isOperator,
63
+ isOwner: a.isOwner || b.isOwner,
64
+ firstSeen: Math.min(a.firstSeen, b.firstSeen),
65
+ lastSeen: Math.max(a.lastSeen, b.lastSeen),
66
+ };
67
+ contacts = contacts.filter(c => c !== a && c !== b);
68
+ contacts.push(merged);
69
+ dirty = true;
70
+ rebuildIndexes();
71
+ return merged;
72
+ }
73
+
74
+ export function upsert(opts: { email?: string; slackId?: string; name?: string; role?: string }): Contact {
75
+ const byE = opts.email ? byEmail.get(opts.email.toLowerCase()) : null;
76
+ const byS = opts.slackId ? bySlackId.get(opts.slackId) : null;
77
+
78
+ if (byE && byS && byE !== byS) {
79
+ const merged = mergeContacts(byE, byS);
80
+ if (opts.name) merged.name = opts.name;
81
+ if (opts.role) merged.role = opts.role;
82
+ merged.lastSeen = Date.now();
83
+ dirty = true;
84
+ save();
85
+ console.log(`[contacts] Merged: ${merged.emails.join(',')} + ${merged.slackIds.join(',')}`);
86
+ return merged;
87
+ }
88
+
89
+ const existing = byE || byS;
90
+ if (existing) {
91
+ let changed = false;
92
+ if (opts.email && !existing.emails.includes(opts.email.toLowerCase())) {
93
+ existing.emails.push(opts.email.toLowerCase());
94
+ changed = true;
95
+ }
96
+ if (opts.slackId && !existing.slackIds.includes(opts.slackId)) {
97
+ existing.slackIds.push(opts.slackId);
98
+ changed = true;
99
+ }
100
+ if (opts.name && opts.name !== existing.name) {
101
+ existing.name = opts.name;
102
+ changed = true;
103
+ }
104
+ if (opts.role && opts.role !== existing.role) {
105
+ existing.role = opts.role;
106
+ changed = true;
107
+ }
108
+ existing.lastSeen = Date.now();
109
+ if (changed) {
110
+ dirty = true;
111
+ rebuildIndexes();
112
+ save();
113
+ }
114
+ return existing;
115
+ }
116
+
117
+ const contact: Contact = {
118
+ id: crypto.randomUUID(),
119
+ name: opts.name || opts.email?.split('@')[0] || 'Unknown',
120
+ emails: opts.email ? [opts.email.toLowerCase()] : [],
121
+ slackIds: opts.slackId ? [opts.slackId] : [],
122
+ role: opts.role,
123
+ isOperator: false,
124
+ firstSeen: Date.now(),
125
+ lastSeen: Date.now(),
126
+ };
127
+ contacts.push(contact);
128
+ dirty = true;
129
+ rebuildIndexes();
130
+ save();
131
+ console.log(`[contacts] New: ${contact.name} (${[...contact.emails, ...contact.slackIds].join(', ')})`);
132
+ return contact;
133
+ }
134
+
135
+ export function seedOperators(whitelist: string[]) {
136
+ for (const email of whitelist) {
137
+ const existing = find({ email });
138
+ const c = upsert({ email, ...(existing ? {} : { name: email.split('@')[0] }) });
139
+ if (!c.isOperator) { c.isOperator = true; dirty = true; }
140
+ }
141
+ save();
142
+ }
143
+
144
+ export function formatUserBlock(contact: Contact | null): string {
145
+ if (!contact) return '<current_user>unknown</current_user>';
146
+ const lines = [`name: ${contact.name}`];
147
+ if (contact.emails.length) lines.push(`email: ${contact.emails[0]}`);
148
+ if (contact.role) lines.push(`title: ${contact.role}`);
149
+ if (contact.isOwner) lines.push('role: owner');
150
+ else if (contact.isOperator) lines.push('role: operator');
151
+ return `<current_user>\n${lines.join('\n')}\n</current_user>`;
152
+ }
153
+
154
+ export function formatRoster(): string {
155
+ if (!contacts.length) return '';
156
+ const operators = contacts.filter(c => c.isOperator);
157
+ const others = contacts.filter(c => !c.isOperator);
158
+ const fmt = (c: Contact, tag: string) => {
159
+ const parts = [c.emails[0] || 'no email', tag];
160
+ if (c.role) parts.push(c.role);
161
+ if (c.slackIds.length) parts.push(`slack:${c.slackIds.join(',')}`);
162
+ return `- ${c.name} (${parts.join(', ')})`;
163
+ };
164
+ const lines = [
165
+ ...operators.map(c => fmt(c, c.isOwner ? 'owner' : 'operator')),
166
+ ...others.map(c => fmt(c, 'contact')),
167
+ ];
168
+ return `<known_contacts>\n${lines.join('\n')}\n</known_contacts>`;
169
+ }
170
+
171
+ export function getByRole(role: string): Contact[] {
172
+ if (role === 'owner') return contacts.filter(c => c.isOwner);
173
+ if (role === 'operator') return contacts.filter(c => c.isOperator);
174
+ return contacts.filter(c => c.role === role);
175
+ }
176
+
177
+ export function getAll(): Contact[] { return contacts; }