shraga 0.0.3 → 0.1.2

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 -27
  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,108 @@
1
+ // FCM (Android) sender — HTTP v1 with a Google service-account OAuth token.
2
+ // HTTP only, no firebase-admin SDK (house rule). Same SA → OAuth pattern as
3
+ // mcp-firebase / mcp-google-drive. Ported from appwrap/examples/push-relay.
4
+ import { createSign } from 'node:crypto';
5
+ import type { PushMessage } from './apns.ts';
6
+
7
+ const env = (k: string) => process.env[k] || '';
8
+
9
+ export interface FcmResult {
10
+ status: number;
11
+ reason?: string;
12
+ }
13
+
14
+ interface ServiceAccount {
15
+ client_email: string;
16
+ private_key: string;
17
+ project_id?: string;
18
+ }
19
+
20
+ function loadServiceAccount(): ServiceAccount | null {
21
+ const raw = env('FCM_SERVICE_ACCOUNT_JSON')
22
+ || (env('FCM_SERVICE_ACCOUNT_B64') ? Buffer.from(env('FCM_SERVICE_ACCOUNT_B64'), 'base64').toString('utf8') : '');
23
+ if (!raw) return null;
24
+ try {
25
+ return JSON.parse(raw) as ServiceAccount;
26
+ } catch (err) {
27
+ console.error('[push] FCM service account parse failed:', err);
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /** True when FCM credentials are present (independent of PUSH_ENABLED). */
33
+ export function fcmConfigured(): boolean {
34
+ return !!loadServiceAccount();
35
+ }
36
+
37
+ function projectId(sa: ServiceAccount): string {
38
+ return env('FCM_PROJECT_ID') || sa.project_id || '';
39
+ }
40
+
41
+ const b64url = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url');
42
+
43
+ // ── OAuth access token from the SA JWT (RS256), cached < 50min ──
44
+ let tokCache: { tok: string; at: number } | null = null;
45
+ async function accessToken(sa: ServiceAccount): Promise<string | null> {
46
+ if (tokCache && Date.now() - tokCache.at < 50 * 60_000) return tokCache.tok;
47
+ const iat = Math.floor(Date.now() / 1000);
48
+ const head = b64url({ alg: 'RS256', typ: 'JWT' });
49
+ const claim = b64url({
50
+ iss: sa.client_email,
51
+ scope: 'https://www.googleapis.com/auth/firebase.messaging',
52
+ aud: 'https://oauth2.googleapis.com/token',
53
+ iat,
54
+ exp: iat + 3600,
55
+ });
56
+ const sig = createSign('RSA-SHA256').update(`${head}.${claim}`).sign(sa.private_key).toString('base64url');
57
+ const res = await fetch('https://oauth2.googleapis.com/token', {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
60
+ body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${head}.${claim}.${sig}`,
61
+ }).catch((err) => { console.error('[push] FCM oauth fetch failed:', err); return null; });
62
+ if (!res || !res.ok) {
63
+ if (res) console.error('[push] FCM oauth rejected:', res.status, (await res.text()).slice(0, 200));
64
+ return null;
65
+ }
66
+ const j = (await res.json()) as { access_token?: string };
67
+ if (!j.access_token) return null;
68
+ tokCache = { tok: j.access_token, at: Date.now() };
69
+ return j.access_token;
70
+ }
71
+
72
+ // FCM data fields must be string→string.
73
+ function stringifyData(data?: Record<string, unknown>): Record<string, string> | undefined {
74
+ if (!data) return undefined;
75
+ const out: Record<string, string> = {};
76
+ for (const [k, v] of Object.entries(data)) out[k] = typeof v === 'string' ? v : JSON.stringify(v);
77
+ return out;
78
+ }
79
+
80
+ /** 404 (NOT_FOUND) or an UNREGISTERED error → the token is dead and should be pruned. */
81
+ export function isFcmTokenDead(status: number, reason?: string): boolean {
82
+ if (status === 404) return true;
83
+ return !!reason && /UNREGISTERED|registration-token-not-registered/i.test(reason);
84
+ }
85
+
86
+ export async function sendFcm(token: string, msg: PushMessage): Promise<FcmResult> {
87
+ const sa = loadServiceAccount();
88
+ if (!sa) return { status: 0, reason: 'no_service_account' };
89
+ const pid = projectId(sa);
90
+ if (!pid) return { status: 0, reason: 'no_project_id' };
91
+ const at = await accessToken(sa);
92
+ if (!at) return { status: 0, reason: 'no_access_token' };
93
+
94
+ const res = await fetch(`https://fcm.googleapis.com/v1/projects/${pid}/messages:send`, {
95
+ method: 'POST',
96
+ headers: { authorization: `Bearer ${at}`, 'content-type': 'application/json' },
97
+ body: JSON.stringify({
98
+ message: {
99
+ token,
100
+ notification: { title: msg.title, body: msg.body },
101
+ ...(msg.data ? { data: stringifyData(msg.data) } : {}),
102
+ },
103
+ }),
104
+ }).catch((err) => { console.error('[push] FCM send fetch failed:', err); return null; });
105
+
106
+ if (!res) return { status: 0, reason: 'fetch_failed' };
107
+ return { status: res.status, reason: res.ok ? undefined : (await res.text()).slice(0, 200) };
108
+ }
@@ -0,0 +1,66 @@
1
+ // PushModule — provider-agnostic send: resolve a user's tokens, route to APNs/FCM,
2
+ // prune dead tokens. Gated OFF unless PUSH_ENABLED=true AND creds for at least one
3
+ // provider are present (zero behavior change otherwise).
4
+ import { sendApns, apnsConfigured, apnsTopic, isApnsTokenDead, type PushMessage } from './apns.ts';
5
+ import { sendFcm, fcmConfigured, isFcmTokenDead } from './fcm.ts';
6
+ import { listForUid, removeToken, touchToken } from './store.ts';
7
+
8
+ const env = (k: string) => process.env[k] || '';
9
+
10
+ /** Master gate: feature flag ON + at least one provider configured. */
11
+ export function pushEnabled(): boolean {
12
+ if (env('PUSH_ENABLED') !== 'true') return false;
13
+ return apnsConfigured() || fcmConfigured();
14
+ }
15
+
16
+ export class PushModuleOptions {
17
+ /** Senders are injectable so triggers/tests can stub them; defaults hit the real providers. */
18
+ sendApns = sendApns;
19
+ sendFcm = sendFcm;
20
+ }
21
+
22
+ export class PushModule {
23
+ public options: PushModuleOptions;
24
+
25
+ public constructor(options?: Partial<PushModuleOptions>) {
26
+ this.options = { ...new PushModuleOptions(), ...options };
27
+ }
28
+
29
+ public enabled(): boolean {
30
+ return pushEnabled();
31
+ }
32
+
33
+ /** Send a notification to every device a user has registered; prune dead tokens. */
34
+ public async send(uid: string, msg: PushMessage): Promise<void> {
35
+ if (!this.enabled()) return;
36
+ const tokens = listForUid(uid);
37
+ if (!tokens.length) return;
38
+ for (const t of tokens) {
39
+ try {
40
+ if (t.platform === 'apns') {
41
+ const r = await this.options.sendApns(t.token, t.topic || apnsTopic(), msg);
42
+ if (r.status >= 200 && r.status < 300) {
43
+ touchToken(uid, t.token);
44
+ } else if (isApnsTokenDead(r.status, r.reason)) {
45
+ console.warn(`[push] pruning dead apns token for ${uid.slice(0, 8)} (${r.status} ${r.reason})`);
46
+ removeToken(uid, t.token);
47
+ } else {
48
+ console.warn(`[push] apns send failed for ${uid.slice(0, 8)}: ${r.status} ${r.reason}`);
49
+ }
50
+ } else {
51
+ const r = await this.options.sendFcm(t.token, msg);
52
+ if (r.status >= 200 && r.status < 300) {
53
+ touchToken(uid, t.token);
54
+ } else if (isFcmTokenDead(r.status, r.reason)) {
55
+ console.warn(`[push] pruning dead fcm token for ${uid.slice(0, 8)} (${r.status})`);
56
+ removeToken(uid, t.token);
57
+ } else {
58
+ console.warn(`[push] fcm send failed for ${uid.slice(0, 8)}: ${r.status} ${r.reason}`);
59
+ }
60
+ }
61
+ } catch (err) {
62
+ console.error(`[push] send error for ${uid.slice(0, 8)} (${t.platform}):`, err);
63
+ }
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,84 @@
1
+ // Flat-file device-token store for remote push — no DB (house rule).
2
+ // `data/push-tokens.json`, keyed by uid → [{ token, platform, topic, addedAt, lastSeen }].
3
+ // Re-registering the same token is an upsert (refreshes lastSeen / platform / topic).
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { dataPath } from '../paths.ts';
7
+
8
+ export type PushPlatform = 'apns' | 'fcm';
9
+
10
+ export interface PushToken {
11
+ token: string;
12
+ platform: PushPlatform;
13
+ topic?: string;
14
+ addedAt: number;
15
+ lastSeen: number;
16
+ }
17
+
18
+ type Store = Record<string, PushToken[]>;
19
+
20
+ const FILE = dataPath('push-tokens.json');
21
+
22
+ function load(): Store {
23
+ if (!existsSync(FILE)) return {};
24
+ try {
25
+ return JSON.parse(readFileSync(FILE, 'utf-8')) as Store;
26
+ } catch (err) {
27
+ console.error('[push] token store read failed, starting empty:', err);
28
+ return {};
29
+ }
30
+ }
31
+
32
+ function save(store: Store): void {
33
+ mkdirSync(path.dirname(FILE), { recursive: true });
34
+ writeFileSync(FILE, JSON.stringify(store, null, 2));
35
+ }
36
+
37
+ /** Insert or refresh a token for a user. A token can only belong to one uid; if it
38
+ * re-appears under a new uid (device handed over) it migrates to the latest owner. */
39
+ export function upsertToken(uid: string, token: string, platform: PushPlatform, topic?: string): void {
40
+ const store = load();
41
+ // Drop this token from any other uid so it isn't double-delivered.
42
+ for (const [otherUid, list] of Object.entries(store)) {
43
+ if (otherUid === uid) continue;
44
+ const filtered = list.filter((t) => t.token !== token);
45
+ if (filtered.length !== list.length) store[otherUid] = filtered;
46
+ }
47
+ const list = store[uid] ?? [];
48
+ const now = Date.now();
49
+ const existing = list.find((t) => t.token === token);
50
+ if (existing) {
51
+ existing.platform = platform;
52
+ existing.topic = topic;
53
+ existing.lastSeen = now;
54
+ } else {
55
+ list.push({ token, platform, topic, addedAt: now, lastSeen: now });
56
+ }
57
+ store[uid] = list;
58
+ save(store);
59
+ }
60
+
61
+ /** Remove a token for a user (unregister, or pruning a dead token). */
62
+ export function removeToken(uid: string, token: string): void {
63
+ const store = load();
64
+ const list = store[uid];
65
+ if (!list) return;
66
+ const filtered = list.filter((t) => t.token !== token);
67
+ if (filtered.length === list.length) return;
68
+ if (filtered.length) store[uid] = filtered;
69
+ else delete store[uid];
70
+ save(store);
71
+ }
72
+
73
+ /** Mark a token as recently delivered-to (housekeeping; cheap). */
74
+ export function touchToken(uid: string, token: string): void {
75
+ const store = load();
76
+ const entry = store[uid]?.find((t) => t.token === token);
77
+ if (!entry) return;
78
+ entry.lastSeen = Date.now();
79
+ save(store);
80
+ }
81
+
82
+ export function listForUid(uid: string): PushToken[] {
83
+ return load()[uid] ?? [];
84
+ }
@@ -0,0 +1,99 @@
1
+ // Push triggers — wire "everything notable" → PushModule.send, mirroring the
2
+ // session-bus IoC pattern: index.ts calls initPushTriggers() at boot with the
3
+ // deps it owns (origin, foreground-presence check), then calls the small
4
+ // pushTurnDone/pushQuestion hooks from inside the request path. schedule.finished
5
+ // is consumed off the event bus. No import of index.ts → no cycle.
6
+ //
7
+ // Light dedup + per-(uid,kind,session) rate-limit so notable events don't spam.
8
+ import { PushModule } from './push.ts';
9
+ import { subscribeEvents } from '../events/bus.ts';
10
+ import { getSession } from '../sessions.ts';
11
+
12
+ export type PushKind = 'turn-done' | 'question' | 'schedule';
13
+
14
+ export interface PushTriggerDeps {
15
+ /** This server's public origin, embedded in `data.instance` for device onTap routing. */
16
+ origin: string;
17
+ /** True if the user has a foreground client actively viewing this session. */
18
+ isForeground: (uid: string, sessionId: string) => boolean;
19
+ }
20
+
21
+ const RATE_MS = 10_000;
22
+
23
+ let push: PushModule | null = null;
24
+ let deps: PushTriggerDeps | null = null;
25
+ const lastSent = new Map<string, number>();
26
+
27
+ export function initPushTriggers(d: PushTriggerDeps): void {
28
+ deps = d;
29
+ push = new PushModule();
30
+ subscribeEvents((evt) => {
31
+ if (evt.source === 'schedule.finished') onScheduleFinished(evt.payload);
32
+ });
33
+ console.log(`[push] triggers initialized (enabled=${push.enabled()}, origin=${d.origin || '<unset>'})`);
34
+ }
35
+
36
+ /** Dedup + rate-limit gate. Returns false if this (uid,kind,session) fired too recently. */
37
+ function gate(uid: string, kind: PushKind, sessionId: string): boolean {
38
+ const key = `${uid}:${kind}:${sessionId}`;
39
+ const now = Date.now();
40
+ const last = lastSent.get(key);
41
+ if (last && now - last < RATE_MS) return false;
42
+ lastSent.set(key, now);
43
+ if (lastSent.size > 1000) {
44
+ for (const [k, ts] of lastSent) if (now - ts > RATE_MS * 6) lastSent.delete(k);
45
+ }
46
+ return true;
47
+ }
48
+
49
+ function data(sessionId: string, kind: PushKind): Record<string, unknown> {
50
+ return { instance: deps?.origin || '', session: sessionId, kind };
51
+ }
52
+
53
+ function titleFor(sessionId: string, fallback: string): string {
54
+ return getSession(sessionId)?.title?.trim() || fallback;
55
+ }
56
+
57
+ /** Turn finished (session_busy → false). Owner only; skip if they're looking at this session. */
58
+ export function pushTurnDone(uid: string, sessionId: string): void {
59
+ if (!push?.enabled() || !deps) return;
60
+ if (deps.isForeground(uid, sessionId)) return;
61
+ if (!gate(uid, 'turn-done', sessionId)) return;
62
+ void push.send(uid, {
63
+ title: titleFor(sessionId, 'Shraga'),
64
+ body: 'The agent finished responding.',
65
+ data: data(sessionId, 'turn-done'),
66
+ });
67
+ }
68
+
69
+ /** Agent is asking the user a question (AskUserQuestion) — always notable. */
70
+ export function pushQuestion(uid: string, sessionId: string): void {
71
+ if (!push?.enabled()) return;
72
+ if (!gate(uid, 'question', sessionId)) return;
73
+ void push.send(uid, {
74
+ title: titleFor(sessionId, 'Shraga'),
75
+ body: 'The agent needs your input.',
76
+ data: data(sessionId, 'question'),
77
+ });
78
+ }
79
+
80
+ function onScheduleFinished(payload: unknown): void {
81
+ if (!push?.enabled()) return;
82
+ const p = (payload || {}) as { scheduleId?: string; name?: string; status?: string; sessionId?: string; error?: string };
83
+ const sessionId = p.sessionId;
84
+ if (!sessionId) return;
85
+ const uid = getSession(sessionId)?.uid;
86
+ if (!uid) return;
87
+ if (!gate(uid, 'schedule', sessionId)) return;
88
+ const body =
89
+ p.status === 'ok'
90
+ ? 'Schedule completed.'
91
+ : p.status === 'aborted'
92
+ ? 'Schedule aborted.'
93
+ : `Schedule failed${p.error ? `: ${p.error.slice(0, 120)}` : '.'}`;
94
+ void push.send(uid, {
95
+ title: p.name || 'Scheduled task',
96
+ body,
97
+ data: data(sessionId, 'schedule'),
98
+ });
99
+ }
@@ -0,0 +1,157 @@
1
+ import type { Schedule } from './types.ts';
2
+
3
+ export const SYSTEM_UID = '__system__';
4
+ export const NIGHTLY_RECONCILE_SCHEDULE_ID = 'builtin-nightly-reconcile';
5
+ export const DAILY_GARDEN_SCHEDULE_ID = 'builtin-daily-garden';
6
+ export const HOURLY_SUMMARIZER_SCHEDULE_ID = 'builtin-conversation-summarizer';
7
+ export const FAILURE_NOTIFIER_SCHEDULE_ID = 'builtin-failure-notifier';
8
+
9
+ /** Generic triage prompt for the failure notifier. Deployments override the prompt
10
+ * (recipients, runbooks, base URL, severity rules) — their edits survive reconcile. */
11
+ const FAILURE_NOTIFIER_PROMPT = [
12
+ 'A scheduled job just FAILED. The failure event payload is included in this message',
13
+ '(fields: name, scheduleId, status, error, sessionId). Duplicate alerts for the same',
14
+ 'job+error are already suppressed by this trigger\'s throttle, so just handle this one.',
15
+ '',
16
+ 'TRIAGE — classify the error:',
17
+ ' - Credential/auth expiry ("token expired", invalid_grant, 401/403, OAuthException): fix = regenerate/refresh the credential.',
18
+ ' - Rate limit (429, "rate limit", "request limit reached"): likely transient/self-healing.',
19
+ ' - Data issue (empty/missing results, 0 rows): check the upstream source.',
20
+ ' - Otherwise: generic failure; surface the raw error.',
21
+ 'Severity is HIGH for revenue/reporting-critical jobs, otherwise NORMAL.',
22
+ '',
23
+ 'NOTIFY — send exactly ONE concise alert to the deployment owner, then stop. Prefer a Slack DM',
24
+ 'if a Slack tool is configured (resolve the owner from $SHRAGA_ALERT_SLACK_EMAIL (or legacy $UNCLAW_ALERT_SLACK_EMAIL), else the first',
25
+ 'entry in data/whitelist.json); otherwise email them. Suggested format:',
26
+ ' :rotating_light: *<category>*<add " [HIGH]" if high severity> — scheduled job failed: *<name>*',
27
+ ' *What:* <one plain-language line>',
28
+ ' *Fix:* <actionable next step from triage>',
29
+ ' *Error:* <error, truncated to ~400 chars, in backticks>',
30
+ ' *Session:* <deployment URL>/?session=<sessionId>',
31
+ 'Do NOT try to fix the job yourself.',
32
+ ].join('\n');
33
+
34
+ export function isSystemSchedule(schedule: Schedule): boolean {
35
+ return schedule.scope === 'system';
36
+ }
37
+
38
+ /** Backfill `scope` and migrate legacy task.kind names on schedules persisted before the rename. */
39
+ export function backfillScope(schedules: Schedule[]): void {
40
+ for (const s of schedules) {
41
+ if (!s.scope) {
42
+ s.scope = s.createdBy?.uid === SYSTEM_UID ? 'system' : 'user';
43
+ }
44
+ const kind = (s.task as { kind: string } | undefined)?.kind;
45
+ if (kind === 'agent') (s.task as { kind: string }).kind = 'prompt';
46
+ else if (kind === 'builtin') (s.task as { kind: string }).kind = 'job';
47
+
48
+ // Migrate legacy jobId-based tasks to command-based
49
+ const task = s.task as any;
50
+ if (task.kind === 'job' && task.jobId && !task.command) {
51
+ const legacyMap: Record<string, string> = {
52
+ 'daily-facebook-ads-spend': 'bun run data/scripts/daily-fb-ads-spend.ts',
53
+ 'daily-payback-sheet': 'bun run data/scripts/daily-payback-sheet.ts --slack',
54
+ 'conversation-summarizer': 'bun run summarize:conversations',
55
+ };
56
+ task.command = legacyMap[task.jobId] ?? task.jobId;
57
+ delete task.jobId;
58
+ }
59
+ }
60
+ }
61
+
62
+ export function ensureBuiltinSchedules(schedules: Schedule[]): Schedule[] {
63
+ const now = Date.now();
64
+ const builtins: Schedule[] = [
65
+ defaultAgentSchedule({
66
+ id: DAILY_GARDEN_SCHEDULE_ID,
67
+ name: 'Daily Knowledge Garden',
68
+ expr: '0 13 * * *',
69
+ prompt: '/garden',
70
+ now,
71
+ }),
72
+ defaultAgentSchedule({
73
+ id: NIGHTLY_RECONCILE_SCHEDULE_ID,
74
+ name: 'Nightly Knowledge Reconciliation',
75
+ expr: '0 3 * * *',
76
+ prompt: '/reconcile',
77
+ now,
78
+ }),
79
+ {
80
+ id: HOURLY_SUMMARIZER_SCHEDULE_ID,
81
+ name: 'Conversation Summarizer',
82
+ enabled: false,
83
+ trigger: { kind: 'cron', expr: '0 * * * *', tz: 'UTC' },
84
+ task: { kind: 'job', command: 'bun run summarize:conversations' },
85
+ scope: 'system',
86
+ createdBy: { uid: SYSTEM_UID, email: 'system@shraga.local' },
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ runCount: 0,
90
+ },
91
+ {
92
+ id: FAILURE_NOTIFIER_SCHEDULE_ID,
93
+ name: 'Scheduled-job failure notifier',
94
+ enabled: false,
95
+ // Fires on any failed schedule run; throttle dedups same job+error within 6h
96
+ // BEFORE spawning a run. (schedule.finished is suppressed for event-triggered
97
+ // runs, so the notifier can't trigger itself.)
98
+ trigger: {
99
+ kind: 'event',
100
+ source: 'schedule.finished',
101
+ match: { status: 'error' },
102
+ throttle: { byFields: ['name', 'error'], windowSec: 21600 },
103
+ },
104
+ task: { kind: 'prompt', prompt: FAILURE_NOTIFIER_PROMPT },
105
+ scope: 'system',
106
+ createdBy: { uid: SYSTEM_UID, email: 'system@shraga.local' },
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ runCount: 0,
110
+ },
111
+ ];
112
+
113
+ const builtinIds = new Set(builtins.map((b) => b.id));
114
+
115
+ // Remove system schedules no longer in the builtins list (renamed/retired)
116
+ for (let i = schedules.length - 1; i >= 0; i--) {
117
+ if (schedules[i].scope === 'system' && schedules[i].createdBy?.uid === SYSTEM_UID && !builtinIds.has(schedules[i].id)) {
118
+ schedules.splice(i, 1);
119
+ }
120
+ }
121
+
122
+ for (const builtin of builtins) {
123
+ const existing = schedules.find((s) => s.id === builtin.id);
124
+ if (existing) {
125
+ existing.name = builtin.name;
126
+ existing.scope = builtin.scope;
127
+ existing.trigger = existing.trigger ?? builtin.trigger;
128
+ existing.createdBy = builtin.createdBy;
129
+ } else {
130
+ schedules.push(builtin);
131
+ }
132
+ }
133
+
134
+ return schedules;
135
+ }
136
+
137
+ function defaultAgentSchedule(options: {
138
+ id: string;
139
+ name: string;
140
+ expr: string;
141
+ prompt: string;
142
+ now: number;
143
+ scope?: 'system' | 'user';
144
+ }): Schedule {
145
+ return {
146
+ id: options.id,
147
+ name: options.name,
148
+ enabled: false,
149
+ trigger: { kind: 'cron', expr: options.expr, tz: 'UTC' },
150
+ task: { kind: 'prompt', prompt: options.prompt },
151
+ scope: options.scope || 'system',
152
+ createdBy: { uid: SYSTEM_UID, email: 'system@shraga.local' },
153
+ createdAt: options.now,
154
+ updatedAt: options.now,
155
+ runCount: 0,
156
+ };
157
+ }