shraga 0.0.2 → 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 +60 -15
- 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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared across add-on engine adapters (multi-provider loop, native cursor).
|
|
3
|
+
* Kept here so those engines don't duplicate event/transcript plumbing.
|
|
4
|
+
*/
|
|
5
|
+
import type { WsEvent } from '../claude.ts';
|
|
6
|
+
import type { ConvMessage } from '../sessions.ts';
|
|
7
|
+
|
|
8
|
+
/** Surface inline preview images embedded in a tool result as a separate image event. */
|
|
9
|
+
export function* extractPreviewImages(output: string, toolUseId: string): Generator<WsEvent> {
|
|
10
|
+
if (!output.includes('preview') || !output.includes('data:image/')) return;
|
|
11
|
+
try {
|
|
12
|
+
const outer = JSON.parse(output);
|
|
13
|
+
// Cursor MCP wraps: {status, value: {content: [{text: {text: "<inner-json>"}}]}}
|
|
14
|
+
const innerRaw = outer?.value?.content?.[0]?.text?.text ?? outer?.value?.content?.[0]?.text;
|
|
15
|
+
const parsed = typeof innerRaw === 'string' ? JSON.parse(innerRaw) : outer;
|
|
16
|
+
const dataUrl = parsed?.preview;
|
|
17
|
+
if (typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')) {
|
|
18
|
+
yield { type: 'tool_result_image', toolUseId, dataUrl } as any;
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Flatten prior conversation turns into a compact <conversation-history> block for engines that lack native multi-turn state. */
|
|
24
|
+
export function buildConversationSummary(conversation: ConvMessage[]): string {
|
|
25
|
+
if (conversation.length <= 1) return '';
|
|
26
|
+
// Skip the last message (it's the current user prompt, already in the prompt)
|
|
27
|
+
const prior = conversation.slice(0, -1);
|
|
28
|
+
const lines: string[] = ['<conversation-history>'];
|
|
29
|
+
for (const msg of prior) {
|
|
30
|
+
const texts = msg.blocks
|
|
31
|
+
.filter((b: any) => b.type === 'text' && b.text)
|
|
32
|
+
.map((b: any) => b.text.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
const tools = msg.blocks
|
|
35
|
+
.filter((b: any) => b.type === 'tool_use')
|
|
36
|
+
.map((b: any) => b.tool);
|
|
37
|
+
const images = msg.blocks.filter((b: any) => b.type === 'image').length;
|
|
38
|
+
if (texts.length || tools.length) {
|
|
39
|
+
lines.push(`[${msg.role}]`);
|
|
40
|
+
for (const t of texts) lines.push(t.length > 500 ? t.slice(0, 500) + '…' : t);
|
|
41
|
+
if (tools.length) lines.push(`(tools used: ${tools.join(', ')})`);
|
|
42
|
+
if (images) lines.push(`(${images} image(s) shown)`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
lines.push('</conversation-history>');
|
|
46
|
+
return lines.length > 2 ? lines.join('\n') : '';
|
|
47
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { McpConfig } from '../mcp.ts';
|
|
2
|
+
import type { WsEvent, AttachmentMeta, PermissionHandler, QuestionHandler } from '../claude.ts';
|
|
3
|
+
import type { ConvMessage } from '../sessions.ts';
|
|
4
|
+
import type { Directives } from '../directives.ts';
|
|
5
|
+
import type { AgentSettings } from '../shraga-config.ts';
|
|
6
|
+
|
|
7
|
+
export interface EngineStreamOpts {
|
|
8
|
+
/** The user's effective prompt (after directive stripping, slash command expansion, skill/workspace mentions) */
|
|
9
|
+
prompt: string;
|
|
10
|
+
/** Raw conversation history from JSONL (engine formats as needed) */
|
|
11
|
+
conversation: ConvMessage[];
|
|
12
|
+
/** Contextual blocks to prepend (user block, skills, workspace tree, etc.) */
|
|
13
|
+
contextBlock: string;
|
|
14
|
+
|
|
15
|
+
attachments?: AttachmentMeta[];
|
|
16
|
+
images?: string[];
|
|
17
|
+
sessionId: string;
|
|
18
|
+
uid: string;
|
|
19
|
+
userEmail?: string;
|
|
20
|
+
userName?: string;
|
|
21
|
+
mcpServers?: McpConfig;
|
|
22
|
+
abortController?: AbortController;
|
|
23
|
+
onPermissionRequest?: PermissionHandler;
|
|
24
|
+
onDestructiveApproval?: PermissionHandler;
|
|
25
|
+
onUserQuestion?: QuestionHandler;
|
|
26
|
+
voiceMode?: boolean;
|
|
27
|
+
/** True when the conversation was truncated (user replayed/edited a message) — engines with cached state should reset. */
|
|
28
|
+
conversationReset?: boolean;
|
|
29
|
+
context?: Record<string, string>;
|
|
30
|
+
|
|
31
|
+
directives: Directives;
|
|
32
|
+
config: AgentSettings;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AgentEngine {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent>;
|
|
38
|
+
/** Return model options for the UI picker (only called when engine is available) */
|
|
39
|
+
getModels(): EngineModel[];
|
|
40
|
+
init?(): Promise<void>;
|
|
41
|
+
shutdown?(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EngineModel {
|
|
45
|
+
value: string;
|
|
46
|
+
label: string;
|
|
47
|
+
provider?: string;
|
|
48
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Resolve and load the correct .env file at startup.
|
|
2
|
+
// Must be imported FIRST — before env-sanitize or any config reader.
|
|
3
|
+
//
|
|
4
|
+
// Resolution order:
|
|
5
|
+
// 1. ENV_NAME env var → .env.{name}
|
|
6
|
+
// 2. --env-file already loaded (has ANTHROPIC_API_KEY) → no-op
|
|
7
|
+
// 3. Scan for .env.* files:
|
|
8
|
+
// - exactly one non-example → auto-select
|
|
9
|
+
// - multiple → error with list
|
|
10
|
+
// - none → fall back to bare .env (prod)
|
|
11
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(import.meta.dir, '../..');
|
|
15
|
+
const TAG = '[env]';
|
|
16
|
+
const SKIP = /\.(example|bak|copy|deploy-restore)/;
|
|
17
|
+
|
|
18
|
+
function alreadyLoaded(): boolean {
|
|
19
|
+
return !!(process.env.ANTHROPIC_API_KEY || process.env.DATA_SYNC_REPO);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findNamedEnvs(): string[] {
|
|
23
|
+
return readdirSync(ROOT)
|
|
24
|
+
.filter(f => f.startsWith('.env.') && !SKIP.test(f))
|
|
25
|
+
.map(f => f.slice(5));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadFile(filePath: string): void {
|
|
29
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
30
|
+
for (const line of content.split('\n')) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
33
|
+
const eq = trimmed.indexOf('=');
|
|
34
|
+
if (eq < 1) continue;
|
|
35
|
+
const key = trimmed.slice(0, eq);
|
|
36
|
+
const val = trimmed.slice(eq + 1).replace(/^["']|["']$/g, '').replace(/\s+#.*$/, '');
|
|
37
|
+
if (!(key in process.env)) process.env[key] = val;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!alreadyLoaded()) {
|
|
42
|
+
const envName = process.env.ENV_NAME;
|
|
43
|
+
|
|
44
|
+
if (envName) {
|
|
45
|
+
const p = path.join(ROOT, `.env.${envName}`);
|
|
46
|
+
if (!existsSync(p)) {
|
|
47
|
+
console.error(`${TAG} ❌ .env.${envName} not found`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
loadFile(p);
|
|
51
|
+
console.log(`${TAG} Loaded .env.${envName}`);
|
|
52
|
+
} else {
|
|
53
|
+
const named = findNamedEnvs();
|
|
54
|
+
const bare = path.join(ROOT, '.env');
|
|
55
|
+
|
|
56
|
+
if (named.length === 1) {
|
|
57
|
+
const p = path.join(ROOT, `.env.${named[0]}`);
|
|
58
|
+
loadFile(p);
|
|
59
|
+
process.env.ENV_NAME = named[0];
|
|
60
|
+
console.log(`${TAG} Auto-selected .env.${named[0]} (only named env)`);
|
|
61
|
+
} else if (named.length > 1 && !existsSync(bare)) {
|
|
62
|
+
console.error(`${TAG} ❌ Multiple named envs found, set ENV_NAME to pick one:`);
|
|
63
|
+
for (const n of named) console.error(`${TAG} .env.${n}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
} else if (existsSync(bare)) {
|
|
66
|
+
loadFile(bare);
|
|
67
|
+
} else {
|
|
68
|
+
console.warn(`${TAG} No .env file found — relying on system environment`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Drop env vars whose value is an unresolved `${VAR}` placeholder left by deploy tooling
|
|
2
|
+
// (deploy tools may write `KEY=${VAR}` when the source var is unset). Such a value is never
|
|
3
|
+
// legitimate — it defeats `process.env.A ?? process.env.B` fallbacks and breaks JSON.parse.
|
|
4
|
+
// Imported first in index.ts so it runs before any config is read.
|
|
5
|
+
const PLACEHOLDER = /^['"]?\$\{[A-Za-z0-9_]+\}['"]?$/;
|
|
6
|
+
|
|
7
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
8
|
+
if (typeof value === 'string' && PLACEHOLDER.test(value.trim())) delete process.env[key];
|
|
9
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Tiny in-process event bus — the driving port for event-based triggers.
|
|
2
|
+
//
|
|
3
|
+
// Sources (the generic /api/events route, data-side extensions, internal watchers)
|
|
4
|
+
// call emitEvent(); the dispatcher subscribes and routes matching events to the
|
|
5
|
+
// scheduler's execution path. Deliberately minimal: no plugin registry, no
|
|
6
|
+
// lifecycle — just emit + subscribe. Adding a source = one emitEvent() call.
|
|
7
|
+
import type { ShragaEvent } from './types.ts';
|
|
8
|
+
|
|
9
|
+
type Listener = (evt: ShragaEvent) => void;
|
|
10
|
+
|
|
11
|
+
const listeners = new Set<Listener>();
|
|
12
|
+
|
|
13
|
+
/** Subscribe to all events. Returns an unsubscribe fn. */
|
|
14
|
+
export function subscribeEvents(fn: Listener): () => void {
|
|
15
|
+
listeners.add(fn);
|
|
16
|
+
return () => listeners.delete(fn);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Publish an event. Listeners are invoked synchronously; a throwing listener is
|
|
20
|
+
* logged and skipped so one bad subscriber can't sink the emit. */
|
|
21
|
+
export function emitEvent(source: string, payload: unknown, opts?: { id?: string }): ShragaEvent {
|
|
22
|
+
const evt: ShragaEvent = { source, payload, id: opts?.id, at: Date.now() };
|
|
23
|
+
for (const fn of listeners) {
|
|
24
|
+
try { fn(evt); } catch (err) {
|
|
25
|
+
console.error(`[events] listener threw for source "${source}":`, err);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return evt;
|
|
29
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Routes bus events to the scheduler. Subscribes once; for each event, dedupes by
|
|
2
|
+
// id (retried webhooks) then asks the scheduler to fire matching `event`-trigger
|
|
3
|
+
// schedules. Actual execution + active-instance gating lives in scheduler.fireEvent.
|
|
4
|
+
import { subscribeEvents } from './bus.ts';
|
|
5
|
+
import * as scheduler from '../scheduler/index.ts';
|
|
6
|
+
|
|
7
|
+
const DEDUPE_TTL_MS = 5 * 60_000;
|
|
8
|
+
const DEDUPE_MAX = 1000;
|
|
9
|
+
/** id → first-seen timestamp. Bounded; old entries pruned on insert. */
|
|
10
|
+
const seen = new Map<string, number>();
|
|
11
|
+
|
|
12
|
+
function isDuplicate(id: string): boolean {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
// Prune expired (and cap size) before checking.
|
|
15
|
+
if (seen.size >= DEDUPE_MAX) {
|
|
16
|
+
for (const [k, ts] of seen) {
|
|
17
|
+
if (now - ts > DEDUPE_TTL_MS) seen.delete(k);
|
|
18
|
+
}
|
|
19
|
+
// Still over cap → drop oldest insertion (Map preserves insertion order).
|
|
20
|
+
while (seen.size >= DEDUPE_MAX) {
|
|
21
|
+
const oldest = seen.keys().next().value;
|
|
22
|
+
if (oldest === undefined) break;
|
|
23
|
+
seen.delete(oldest);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const prev = seen.get(id);
|
|
27
|
+
if (prev !== undefined && now - prev <= DEDUPE_TTL_MS) return true;
|
|
28
|
+
seen.set(id, now);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let started = false;
|
|
33
|
+
|
|
34
|
+
export function startEventDispatcher(): void {
|
|
35
|
+
if (started) return; // idempotent — a double subscribe would double-fire every event
|
|
36
|
+
started = true;
|
|
37
|
+
subscribeEvents((evt) => {
|
|
38
|
+
if (evt.id && isDuplicate(evt.id)) {
|
|
39
|
+
console.log(`[events] duplicate ${evt.source}#${evt.id} — ignored`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const fired = scheduler.fireEvent(evt.source, evt.payload);
|
|
43
|
+
if (fired.length) {
|
|
44
|
+
console.log(`[events] "${evt.source}" → fired ${fired.length} trigger(s): ${fired.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
console.log('[events] dispatcher started');
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Generic inbound event endpoint: POST /api/events/:source
|
|
2
|
+
//
|
|
3
|
+
// For trusted callers that can present shraga auth (API key `uck_…`, bearer, or the
|
|
4
|
+
// internal token). The body becomes the event payload; an optional `X-Event-Id`
|
|
5
|
+
// header dedupes retries. Vendor webhooks that can't send shraga auth (Stripe,
|
|
6
|
+
// GitHub, …) should instead live in a data-side extension that verifies the
|
|
7
|
+
// vendor's own signature and calls ctx.emitEvent() server-side.
|
|
8
|
+
import type { Express, RequestHandler } from 'express';
|
|
9
|
+
import { emitEvent } from './bus.ts';
|
|
10
|
+
|
|
11
|
+
export function registerEventRoutes(app: Express, requireAuth: RequestHandler): void {
|
|
12
|
+
app.post('/api/events/:source', requireAuth, (req, res) => {
|
|
13
|
+
const source = String(req.params.source || '').trim();
|
|
14
|
+
if (!source) return res.status(400).json({ error: 'Missing source' });
|
|
15
|
+
const id = typeof req.headers['x-event-id'] === 'string' ? req.headers['x-event-id'] : undefined;
|
|
16
|
+
const evt = emitEvent(source, req.body ?? {}, { id });
|
|
17
|
+
res.json({ ok: true, source, at: evt.at });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A single event flowing through the bus. `source` routes it to matching
|
|
2
|
+
* `event`-trigger schedules; `payload` is matched against their `match` filter and
|
|
3
|
+
* injected into the run. `id` (optional) dedupes retried webhook deliveries. */
|
|
4
|
+
export interface ShragaEvent {
|
|
5
|
+
source: string;
|
|
6
|
+
payload: unknown;
|
|
7
|
+
id?: string;
|
|
8
|
+
at: number;
|
|
9
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Generic loader for deployment-specific HTTP routes ("extensions").
|
|
2
|
+
//
|
|
3
|
+
// Drop a `*.ext.ts` file into the data dir's `extensions/` folder that exports a
|
|
4
|
+
// default `register(router, ctx)` function and it's mounted — into a persistent
|
|
5
|
+
// Router that is itself mounted BEFORE the SPA catch-all (`app.get('*')`). That
|
|
6
|
+
// ordering is the whole trick: a route registered after the catch-all is silently
|
|
7
|
+
// swallowed → returns SPA HTML.
|
|
8
|
+
//
|
|
9
|
+
// HOT-RELOAD: NEW `*.ext.ts` files are picked up at runtime (a dir watch re-scans)
|
|
10
|
+
// — no server restart needed. Because routes are added to the already-mounted
|
|
11
|
+
// Router, they're reachable immediately and still sit before the catch-all.
|
|
12
|
+
// CAVEAT: EDITING an already-loaded file does NOT hot-reload (ESM module cache;
|
|
13
|
+
// re-importing would stack duplicate handlers) — that still needs a restart.
|
|
14
|
+
//
|
|
15
|
+
// Keeps one-off / per-deployment flows that must live on this instance's own
|
|
16
|
+
// public URL (OAuth/manifest callbacks, webhooks) OUT of shared `src/`.
|
|
17
|
+
// See the self-aware skill, "Where one-off / custom code goes".
|
|
18
|
+
import express, { type Express, type Router as ExpressRouter, type RequestHandler } from 'express';
|
|
19
|
+
import { existsSync, readdirSync, watch } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
import { dataPath } from './paths.ts';
|
|
23
|
+
import { requireAuth } from './auth.ts';
|
|
24
|
+
import { emitEvent } from './events/bus.ts';
|
|
25
|
+
|
|
26
|
+
export interface ExtensionContext {
|
|
27
|
+
/** Resolve a path inside the active data dir (e.g. ctx.dataPath('github-app.json')). */
|
|
28
|
+
dataPath: (p: string) => string;
|
|
29
|
+
/** Express middleware enforcing auth (bearer / api-key / internal token / ?token=). */
|
|
30
|
+
requireAuth: RequestHandler;
|
|
31
|
+
/** Prefixed logger. */
|
|
32
|
+
log: (...a: unknown[]) => void;
|
|
33
|
+
/** The root Express app, for the rare extension needing app-level middleware. */
|
|
34
|
+
app: Express;
|
|
35
|
+
/** Publish an event onto the bus → fires matching `event`-trigger schedules.
|
|
36
|
+
* Use after verifying a vendor webhook's own signature. */
|
|
37
|
+
emitEvent: (source: string, payload: unknown, opts?: { id?: string }) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const loaded = new Set<string>();
|
|
41
|
+
let extRouter: ExpressRouter | null = null;
|
|
42
|
+
let ctx: ExtensionContext | null = null;
|
|
43
|
+
|
|
44
|
+
async function mountFile(dir: string, f: string): Promise<void> {
|
|
45
|
+
if (loaded.has(f) || !extRouter || !ctx) return;
|
|
46
|
+
loaded.add(f); // claim before the await, so overlapping scans can't double-mount the same file
|
|
47
|
+
try {
|
|
48
|
+
const mod = await import(pathToFileURL(path.join(dir, f)).href);
|
|
49
|
+
const register = mod.default ?? mod.register;
|
|
50
|
+
if (typeof register !== 'function') {
|
|
51
|
+
loaded.delete(f); // release so a fixed file is retried on its next change event
|
|
52
|
+
console.warn(`[extensions] ${f}: no default export (register fn) — skipped`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Routes register onto the persistent Router (mounted before the catch-all).
|
|
56
|
+
await register(extRouter, ctx);
|
|
57
|
+
console.log(`[extensions] mounted ${f}`);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
loaded.delete(f); // import/register threw — allow a retry on the next change
|
|
60
|
+
console.error(`[extensions] failed to mount ${f}:`, (err as Error)?.stack || err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function scan(dir: string): Promise<void> {
|
|
65
|
+
if (!existsSync(dir)) return;
|
|
66
|
+
for (const f of readdirSync(dir).filter((f) => f.endsWith('.ext.ts')).sort()) {
|
|
67
|
+
await mountFile(dir, f);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function loadExtensions(app: Express): Promise<void> {
|
|
72
|
+
extRouter = express.Router();
|
|
73
|
+
app.use(extRouter); // BEFORE the SPA catch-all (loadExtensions is called before it in index.ts)
|
|
74
|
+
ctx = {
|
|
75
|
+
dataPath,
|
|
76
|
+
requireAuth: requireAuth as unknown as RequestHandler,
|
|
77
|
+
log: (...a) => console.log('[extensions]', ...a),
|
|
78
|
+
app,
|
|
79
|
+
emitEvent,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const dir = path.resolve(dataPath('extensions'));
|
|
83
|
+
await scan(dir);
|
|
84
|
+
|
|
85
|
+
// Hot-reload: re-scan when a *.ext.ts file appears at runtime (debounced).
|
|
86
|
+
if (existsSync(dir)) {
|
|
87
|
+
try {
|
|
88
|
+
let t: ReturnType<typeof setTimeout> | null = null;
|
|
89
|
+
watch(dir, (_evt, filename) => {
|
|
90
|
+
if (!filename || !String(filename).endsWith('.ext.ts')) return;
|
|
91
|
+
if (t) clearTimeout(t);
|
|
92
|
+
t = setTimeout(() => {
|
|
93
|
+
scan(dir).catch((err) => console.error('[extensions] rescan failed:', (err as Error).message));
|
|
94
|
+
}, 300);
|
|
95
|
+
});
|
|
96
|
+
console.log('[extensions] watching for new *.ext.ts (hot-reload of new files, no restart)');
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.warn('[extensions] dir watch unavailable (new files need a restart):', (err as Error).message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Server feature seam — the drop-in point for optional add-ons.
|
|
2
|
+
//
|
|
3
|
+
// The core mounts only the seam; it registers NOTHING here. An optional add-on (a downstream
|
|
4
|
+
// distribution or a private module) imports this and calls registerFeature({...}) for each
|
|
5
|
+
// add-on surface BEFORE the server starts, then those get
|
|
6
|
+
// mounted at the one `mountFeatures()` call in index.ts. This keeps add-on code entirely out of
|
|
7
|
+
// the core tree while giving it a clean, typed way in.
|
|
8
|
+
//
|
|
9
|
+
// Deeper than the data-side `*.ext.ts` extensions (which are just routes): a feature gets the
|
|
10
|
+
// live app + broadcast + auth so it can wire websockets, background workers, recovery, etc.
|
|
11
|
+
|
|
12
|
+
import type { Express, RequestHandler } from 'express';
|
|
13
|
+
|
|
14
|
+
export interface FeatureContext {
|
|
15
|
+
app: Express;
|
|
16
|
+
requireAuth: RequestHandler;
|
|
17
|
+
/** Push a server event to all connected clients (same fn the core uses). */
|
|
18
|
+
broadcast: (event: object) => void;
|
|
19
|
+
/** True in passive/standby mode — features should skip background writers/consumers. */
|
|
20
|
+
passive: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ServerFeature {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
/** Mount routes/handlers/consumers. Throwing is contained (logged, other features still load). */
|
|
26
|
+
register(ctx: FeatureContext): void;
|
|
27
|
+
/** Optional: resume an interrupted session for this feature's channel (called by recovery). */
|
|
28
|
+
resumeSession?(session: unknown, prompt: string): Promise<void> | void;
|
|
29
|
+
/**
|
|
30
|
+
* Optional: client-facing capability flags this feature contributes to `/api/features`.
|
|
31
|
+
* A static map or a getter (evaluated per request, so it can honor env). Merged OVER the core
|
|
32
|
+
* flags. This is how an add-on surface turns itself on WITHOUT the core naming it — the core
|
|
33
|
+
* declares none; an add-on's feature declares its own flag (e.g. `{ someSurface: true }`).
|
|
34
|
+
*/
|
|
35
|
+
readonly flags?: Record<string, boolean> | (() => Record<string, boolean>);
|
|
36
|
+
/**
|
|
37
|
+
* Optional: sidecar WebSocket proxy routes this feature contributes (url-prefix → localhost port).
|
|
38
|
+
* Merged into the core WS proxy table so a feature's daemon socket is reachable WITHOUT the core
|
|
39
|
+
* naming the prefix. Same generic bridge as `flags`.
|
|
40
|
+
*/
|
|
41
|
+
readonly sidecarRoutes?: Record<string, number>;
|
|
42
|
+
/**
|
|
43
|
+
* Optional: MCP server names that must ALWAYS be handed to an engine which narrows its MCP set for
|
|
44
|
+
* speed (rather than only when the prompt happens to mention them). A feature that depends on one
|
|
45
|
+
* of its own MCP servers declares it here, so the core never has to name it. Static or a getter
|
|
46
|
+
* (evaluated per call, so it can honor env) — same shape as `flags`.
|
|
47
|
+
*/
|
|
48
|
+
readonly alwaysMcp?: string[] | (() => string[]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const registry: ServerFeature[] = [];
|
|
52
|
+
|
|
53
|
+
/** Register an add-on feature. Called by an optional add-on before startup. Idempotent by name. */
|
|
54
|
+
export function registerFeature(feature: ServerFeature): void {
|
|
55
|
+
if (registry.some((f) => f.name === feature.name)) return;
|
|
56
|
+
registry.push(feature);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Mount all registered features. The single seam call in index.ts. */
|
|
60
|
+
export function mountFeatures(ctx: FeatureContext): void {
|
|
61
|
+
for (const f of registry) {
|
|
62
|
+
try {
|
|
63
|
+
f.register(ctx);
|
|
64
|
+
console.log(`[features] mounted ${f.name}`);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error(`[features] failed to mount ${f.name}:`, (err as Error)?.stack || err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Merge every registered feature's contributed capability flags. Merged OVER core flags in
|
|
72
|
+
* `/api/features` so an add-on feature's declaration wins. The core contributes nothing here. */
|
|
73
|
+
export function collectFeatureFlags(): Record<string, boolean> {
|
|
74
|
+
const out: Record<string, boolean> = {};
|
|
75
|
+
for (const f of registry) {
|
|
76
|
+
if (!f.flags) continue;
|
|
77
|
+
Object.assign(out, typeof f.flags === 'function' ? f.flags() : f.flags);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Merge every registered feature's sidecar WS proxy routes (prefix → port). Folded into the
|
|
83
|
+
* core WS proxy table after startup so feature daemons are reachable. */
|
|
84
|
+
export function collectSidecarRoutes(): Record<string, number> {
|
|
85
|
+
const out: Record<string, number> = {};
|
|
86
|
+
for (const f of registry) if (f.sidecarRoutes) Object.assign(out, f.sidecarRoutes);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every MCP server name registered features insist on always having. The core contributes NONE, so
|
|
91
|
+
* an engine that narrows its MCP set starts from empty and names no add-on server itself. */
|
|
92
|
+
export function collectAlwaysMcp(): Set<string> {
|
|
93
|
+
const out = new Set<string>();
|
|
94
|
+
for (const f of registry) {
|
|
95
|
+
if (!f.alwaysMcp) continue;
|
|
96
|
+
for (const n of typeof f.alwaysMcp === 'function' ? f.alwaysMcp() : f.alwaysMcp) out.add(n);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Let a registered feature resume its own interrupted sessions (used by crash recovery). */
|
|
102
|
+
export function resumeFeatureSession(channel: string, session: unknown, prompt: string): boolean {
|
|
103
|
+
const f = registry.find((x) => x.name === channel && x.resumeSession);
|
|
104
|
+
if (!f) return false;
|
|
105
|
+
Promise.resolve(f.resumeSession!(session, prompt)).catch((err) =>
|
|
106
|
+
console.error(`[features] ${channel} resume failed:`, (err as Error).message),
|
|
107
|
+
);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface InjectFileOptions {
|
|
5
|
+
maxChars?: number;
|
|
6
|
+
label?: string;
|
|
7
|
+
/** Transform raw content before truncation. 'conversation' extracts text-only blocks from JSONL. */
|
|
8
|
+
transform?: 'conversation' | ((content: string) => string);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function transformConversation(raw: string): string {
|
|
12
|
+
return raw.split('\n').filter(Boolean).flatMap(line => {
|
|
13
|
+
try {
|
|
14
|
+
const msg = JSON.parse(line);
|
|
15
|
+
if (!msg.blocks || !msg.role) return [];
|
|
16
|
+
return msg.blocks
|
|
17
|
+
.filter((b: any) => b.type === 'text')
|
|
18
|
+
.map((b: any) => `[${msg.role}]: ${b.text}`);
|
|
19
|
+
} catch { return []; }
|
|
20
|
+
}).join('\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function injectFile(filePath: string, opts?: InjectFileOptions): string {
|
|
24
|
+
if (!existsSync(filePath)) return '';
|
|
25
|
+
const label = opts?.label ?? 'file';
|
|
26
|
+
const maxChars = opts?.maxChars ?? 2000;
|
|
27
|
+
const unlimited = maxChars <= 0;
|
|
28
|
+
const name = path.basename(filePath);
|
|
29
|
+
let content: string;
|
|
30
|
+
try {
|
|
31
|
+
content = readFileSync(filePath, 'utf-8').trim();
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.warn(`[file-inject] Failed to read ${filePath}:`, e);
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
if (!content) return '';
|
|
37
|
+
if (opts?.transform === 'conversation') content = transformConversation(content);
|
|
38
|
+
else if (typeof opts?.transform === 'function') content = opts.transform(content);
|
|
39
|
+
if (!content) return '';
|
|
40
|
+
const truncated = !unlimited && content.length > maxChars;
|
|
41
|
+
const body = truncated
|
|
42
|
+
? `${content.slice(0, maxChars)}\n… (truncated at ${maxChars} chars — full file: ${filePath})`
|
|
43
|
+
: content;
|
|
44
|
+
return `<${label} path="${filePath}">\n# ${name}\n${body}\n</${label}>`;
|
|
45
|
+
}
|