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.
- package/README.md +82 -23
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- 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; }
|