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,559 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { DATA_DIR, dataPath } from './paths.ts';
|
|
6
|
+
import type { Directives } from './directives.ts';
|
|
7
|
+
|
|
8
|
+
const SESSIONS_PATH = dataPath('sessions.json');
|
|
9
|
+
|
|
10
|
+
export interface SessionMeta {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
title: string;
|
|
13
|
+
userEmail: string;
|
|
14
|
+
userName: string;
|
|
15
|
+
uid: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
lastModified: number;
|
|
18
|
+
autoApprove?: boolean;
|
|
19
|
+
/** Visibility: 'system' is shared across whitelisted users; 'user' (default) is private to `uid`. */
|
|
20
|
+
scope?: 'system' | 'user';
|
|
21
|
+
/** 'terminal' = a PTY-only session (created to host a standalone/terminal-first shell); hidden from
|
|
22
|
+
* the conversation list until it receives a real prompt (then it graduates to a normal conversation). */
|
|
23
|
+
kind?: 'terminal';
|
|
24
|
+
scheduleId?: string;
|
|
25
|
+
scheduleRunAt?: number;
|
|
26
|
+
scheduleRunStatus?: 'running' | 'ok' | 'error' | 'aborted';
|
|
27
|
+
slackContext?: { type: 'dm' | 'channel' | 'mention'; channelName?: string; userName?: string };
|
|
28
|
+
/** Slack message ts values already merged into this session's context (dedup for thread sync). */
|
|
29
|
+
seenSlackTs?: string[];
|
|
30
|
+
/** Skill names trigger-matched earlier in this session — re-injected on every subsequent turn. */
|
|
31
|
+
triggeredSkills?: string[];
|
|
32
|
+
/** Emails allowed to see this session (lowercase). Checked in addition to uid/scope. */
|
|
33
|
+
visibleTo?: string[];
|
|
34
|
+
runStatus?: 'running' | 'idle';
|
|
35
|
+
runOrigin?: 'web' | 'slack' | 'scheduler';
|
|
36
|
+
lastStopReason?: 'max_turns_reached' | 'error' | 'aborted';
|
|
37
|
+
runRetryCount?: number;
|
|
38
|
+
directives?: Directives;
|
|
39
|
+
/** Actual model the engine resolved at runtime (from the SDK init message) — ground truth, unlike directives.model which is the request. */
|
|
40
|
+
lastModel?: string;
|
|
41
|
+
forkedFrom?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadIndex(): SessionMeta[] {
|
|
45
|
+
if (!existsSync(SESSIONS_PATH)) return [];
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(SESSIONS_PATH, 'utf-8'));
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveIndex(sessions: SessionMeta[]): void {
|
|
54
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
55
|
+
writeFileSync(SESSIONS_PATH, JSON.stringify(sessions, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function summarize(prompt: string): string {
|
|
59
|
+
const cleaned = prompt.replace(/\s+/g, ' ').trim();
|
|
60
|
+
if (cleaned.length <= 80) return cleaned;
|
|
61
|
+
return cleaned.slice(0, 77) + '…';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function upsertSession(sessionId: string, firstPrompt: string, user: { uid: string; email: string; name?: string }, scope?: 'system' | 'user', kind?: 'terminal'): void {
|
|
65
|
+
const sessions = loadIndex();
|
|
66
|
+
const existing = sessions.find((s) => s.sessionId === sessionId);
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
|
|
69
|
+
if (existing) {
|
|
70
|
+
existing.lastModified = now;
|
|
71
|
+
if (!existing.title || existing.title === 'New session') {
|
|
72
|
+
existing.title = summarize(firstPrompt);
|
|
73
|
+
}
|
|
74
|
+
// A terminal-only session that now receives a real prompt graduates to a normal conversation.
|
|
75
|
+
if (existing.kind === 'terminal' && kind !== 'terminal') delete existing.kind;
|
|
76
|
+
} else {
|
|
77
|
+
sessions.unshift({
|
|
78
|
+
sessionId,
|
|
79
|
+
title: summarize(firstPrompt),
|
|
80
|
+
userEmail: user.email,
|
|
81
|
+
userName: user.name || user.email.split('@')[0],
|
|
82
|
+
uid: user.uid,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
lastModified: now,
|
|
85
|
+
...(scope ? { scope } : {}),
|
|
86
|
+
...(kind ? { kind } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
saveIndex(sessions);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function setSlackContext(sessionId: string, ctx: NonNullable<SessionMeta['slackContext']>): void {
|
|
94
|
+
const sessions = loadIndex();
|
|
95
|
+
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
96
|
+
if (s) {
|
|
97
|
+
s.slackContext = ctx;
|
|
98
|
+
saveIndex(sessions);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Record Slack ts values as merged into this session's context (dedup for thread sync). */
|
|
103
|
+
export function recordSeenSlackTs(sessionId: string, ts: string[]): void {
|
|
104
|
+
if (!ts.length) return;
|
|
105
|
+
const sessions = loadIndex();
|
|
106
|
+
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
107
|
+
if (!s) return;
|
|
108
|
+
const set = new Set(s.seenSlackTs ?? []);
|
|
109
|
+
for (const t of ts) set.add(t);
|
|
110
|
+
s.seenSlackTs = [...set];
|
|
111
|
+
saveIndex(sessions);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Persist trigger-matched skill names so they stay injected for the rest of the session. */
|
|
115
|
+
export function addTriggeredSkills(sessionId: string, names: string[]): void {
|
|
116
|
+
if (!names.length) return;
|
|
117
|
+
const sessions = loadIndex();
|
|
118
|
+
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
119
|
+
if (!s) return;
|
|
120
|
+
const merged = [...new Set([...(s.triggeredSkills ?? []), ...names])];
|
|
121
|
+
if (merged.length === (s.triggeredSkills?.length ?? 0)) return;
|
|
122
|
+
s.triggeredSkills = merged;
|
|
123
|
+
saveIndex(sessions);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getAllSessions(): SessionMeta[] {
|
|
127
|
+
return loadIndex().sort((a, b) => b.lastModified - a.lastModified);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function isSessionVisibleTo(s: SessionMeta, uid: string, isOwner = false, email?: string): boolean {
|
|
131
|
+
if (isOwner) return true;
|
|
132
|
+
if (s.scope === 'system') return true;
|
|
133
|
+
if (s.visibleTo && email && s.visibleTo.includes(email.toLowerCase())) return true;
|
|
134
|
+
return s.uid === uid;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getSessionsVisibleTo(uid: string, isOwner = false, email?: string): SessionMeta[] {
|
|
138
|
+
return getAllSessions().filter((s) => isSessionVisibleTo(s, uid, isOwner, email));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function setVisibleTo(sessionId: string, emails: string[]): void {
|
|
142
|
+
const sessions = loadIndex();
|
|
143
|
+
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
144
|
+
if (s) {
|
|
145
|
+
s.visibleTo = emails.map((e) => e.toLowerCase());
|
|
146
|
+
saveIndex(sessions);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getSession(sessionId: string): SessionMeta | undefined {
|
|
151
|
+
return loadIndex().find((s) => s.sessionId === sessionId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function updateSessionTitle(sessionId: string, title: string): void {
|
|
155
|
+
const sessions = loadIndex();
|
|
156
|
+
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
157
|
+
if (s) {
|
|
158
|
+
s.title = title;
|
|
159
|
+
saveIndex(sessions);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function generateSessionTitle(sessionId: string, userPrompt: string, assistantText: string): Promise<string | null> {
|
|
164
|
+
try {
|
|
165
|
+
const { runTextQuery } = await import('./sdk-utils.ts');
|
|
166
|
+
const title = await runTextQuery({
|
|
167
|
+
prompt: `Generate a very short title (3-6 words, no quotes) for this conversation:\n\nUser: ${userPrompt.slice(0, 300)}\nAssistant: ${assistantText.slice(0, 500)}`,
|
|
168
|
+
systemPrompt: 'You generate concise conversation titles. Reply with ONLY the title, nothing else. No quotes, no punctuation at the end.',
|
|
169
|
+
});
|
|
170
|
+
const cleaned = title.trim().replace(/^["']|["']$/g, '').replace(/\.+$/, '');
|
|
171
|
+
if (!cleaned) return null;
|
|
172
|
+
updateSessionTitle(sessionId, cleaned);
|
|
173
|
+
return cleaned;
|
|
174
|
+
} catch (err: any) {
|
|
175
|
+
console.error(`[sessions] Title generation failed for ${sessionId.slice(0, 8)}:`, err.message);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function setSessionDirectives(sessionId: string, directives: NonNullable<SessionMeta['directives']>): void {
|
|
181
|
+
const sessions = loadIndex();
|
|
182
|
+
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
183
|
+
if (s) {
|
|
184
|
+
s.directives = directives;
|
|
185
|
+
saveIndex(sessions);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Resolved model per running session (set by the engine from the SDK init message).
|
|
190
|
+
// appendMessage stamps assistant messages from this map so every channel records it.
|
|
191
|
+
const liveModels = new Map<string, string>();
|
|
192
|
+
|
|
193
|
+
/** Last resolved model for a session (live map first, then persisted lastModel). */
|
|
194
|
+
export function getSessionModel(sessionId: string): string | undefined {
|
|
195
|
+
return liveModels.get(sessionId) ?? loadIndex().find((s) => s.sessionId === sessionId)?.lastModel;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function setSessionModel(sessionId: string, model: string): void {
|
|
199
|
+
if (liveModels.get(sessionId) === model) return;
|
|
200
|
+
liveModels.set(sessionId, model);
|
|
201
|
+
const sessions = loadIndex();
|
|
202
|
+
const s = sessions.find((s) => s.sessionId === sessionId);
|
|
203
|
+
if (s && s.lastModel !== model) {
|
|
204
|
+
s.lastModel = model;
|
|
205
|
+
saveIndex(sessions);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getSessionsByScheduleId(scheduleId: string): SessionMeta[] {
|
|
210
|
+
return loadIndex()
|
|
211
|
+
.filter((s) => s.scheduleId === scheduleId)
|
|
212
|
+
.sort((a, b) => b.lastModified - a.lastModified);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function createScheduledSession(sessionId: string, scheduleId: string, title: string, createdBy: { uid: string; email: string }, scope: 'system' | 'user' = 'user'): void {
|
|
216
|
+
const sessions = loadIndex();
|
|
217
|
+
if (sessions.find((s) => s.sessionId === sessionId)) return;
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
sessions.unshift({
|
|
220
|
+
sessionId,
|
|
221
|
+
title,
|
|
222
|
+
userEmail: createdBy.email,
|
|
223
|
+
userName: createdBy.email.split('@')[0],
|
|
224
|
+
uid: createdBy.uid,
|
|
225
|
+
createdAt: now,
|
|
226
|
+
lastModified: now,
|
|
227
|
+
scope,
|
|
228
|
+
scheduleId,
|
|
229
|
+
scheduleRunAt: now,
|
|
230
|
+
scheduleRunStatus: 'running',
|
|
231
|
+
});
|
|
232
|
+
saveIndex(sessions);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function updateScheduledSessionStatus(sessionId: string, status: 'ok' | 'error' | 'aborted'): void {
|
|
236
|
+
const sessions = loadIndex();
|
|
237
|
+
const s = sessions.find((x) => x.sessionId === sessionId);
|
|
238
|
+
if (!s) return;
|
|
239
|
+
s.scheduleRunStatus = status;
|
|
240
|
+
s.lastModified = Date.now();
|
|
241
|
+
saveIndex(sessions);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getAutoApprove(uid: string): boolean {
|
|
245
|
+
const sessions = loadIndex();
|
|
246
|
+
const userSession = sessions.find((s) => s.uid === uid);
|
|
247
|
+
return userSession?.autoApprove ?? false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function setAutoApprove(uid: string, value: boolean): void {
|
|
251
|
+
const sessions = loadIndex();
|
|
252
|
+
// Set on all sessions for this user
|
|
253
|
+
let found = false;
|
|
254
|
+
for (const s of sessions) {
|
|
255
|
+
if (s.uid === uid) {
|
|
256
|
+
s.autoApprove = value;
|
|
257
|
+
found = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (found) saveIndex(sessions);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let _shuttingDown = false;
|
|
264
|
+
export function setShuttingDown(): void { _shuttingDown = true; }
|
|
265
|
+
|
|
266
|
+
export function setRunStatus(sessionId: string, status: 'running' | 'idle', origin?: 'web' | 'slack' | 'scheduler', stopReason?: SessionMeta['lastStopReason']): void {
|
|
267
|
+
if (_shuttingDown && status === 'idle') return;
|
|
268
|
+
const sessions = loadIndex();
|
|
269
|
+
const s = sessions.find((x) => x.sessionId === sessionId);
|
|
270
|
+
if (!s) return;
|
|
271
|
+
s.runStatus = status;
|
|
272
|
+
if (origin) s.runOrigin = origin;
|
|
273
|
+
if (status === 'idle') s.lastStopReason = stopReason;
|
|
274
|
+
s.lastModified = Date.now();
|
|
275
|
+
saveIndex(sessions);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function incrementRetryCount(sessionId: string): void {
|
|
279
|
+
const sessions = loadIndex();
|
|
280
|
+
const s = sessions.find((x) => x.sessionId === sessionId);
|
|
281
|
+
if (!s) return;
|
|
282
|
+
s.runRetryCount = (s.runRetryCount ?? 0) + 1;
|
|
283
|
+
saveIndex(sessions);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function resetRetryCount(sessionId: string): void {
|
|
287
|
+
const sessions = loadIndex();
|
|
288
|
+
const s = sessions.find((x) => x.sessionId === sessionId);
|
|
289
|
+
if (!s || !s.runRetryCount) return;
|
|
290
|
+
s.runRetryCount = 0;
|
|
291
|
+
saveIndex(sessions);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function getRunningSessions(): SessionMeta[] {
|
|
295
|
+
return loadIndex().filter((s) => s.runStatus === 'running');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Global session lock ─────────────────────────────────────────────────────
|
|
299
|
+
// Single source of truth for "is a session currently running a query?"
|
|
300
|
+
// All paths (WebSocket, Slack, REST API, scheduler) must acquire before streaming.
|
|
301
|
+
|
|
302
|
+
interface SessionLock {
|
|
303
|
+
origin: 'web' | 'slack' | 'scheduler' | 'api';
|
|
304
|
+
abortController: AbortController;
|
|
305
|
+
startedAt: number;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const globalSessionLocks = new Map<string, SessionLock>();
|
|
309
|
+
|
|
310
|
+
export function acquireSessionLock(sessionId: string, origin: SessionLock['origin'], abortController: AbortController): boolean {
|
|
311
|
+
if (globalSessionLocks.has(sessionId)) return false;
|
|
312
|
+
globalSessionLocks.set(sessionId, { origin, abortController, startedAt: Date.now() });
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function releaseSessionLock(sessionId: string, owner?: AbortController): boolean {
|
|
317
|
+
if (owner) {
|
|
318
|
+
const lock = globalSessionLocks.get(sessionId);
|
|
319
|
+
if (lock && lock.abortController !== owner) return false;
|
|
320
|
+
}
|
|
321
|
+
globalSessionLocks.delete(sessionId);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function isSessionLocked(sessionId: string): boolean {
|
|
326
|
+
return globalSessionLocks.has(sessionId);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function replaceSessionLock(sessionId: string, origin: SessionLock['origin'], abortController: AbortController): void {
|
|
330
|
+
globalSessionLocks.set(sessionId, { origin, abortController, startedAt: Date.now() });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function getSessionAbortController(sessionId: string): AbortController | undefined {
|
|
334
|
+
return globalSessionLocks.get(sessionId)?.abortController;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const CLAUDE_PROJECTS_DIR = path.join(homedir(), '.claude', 'projects');
|
|
338
|
+
|
|
339
|
+
function findSessionFile(sessionId: string): string | null {
|
|
340
|
+
if (!existsSync(CLAUDE_PROJECTS_DIR)) return null;
|
|
341
|
+
for (const dir of readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true })) {
|
|
342
|
+
if (!dir.isDirectory()) continue;
|
|
343
|
+
const file = path.join(CLAUDE_PROJECTS_DIR, dir.name, `${sessionId}.jsonl`);
|
|
344
|
+
if (existsSync(file)) return file;
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Our own conversation store ───────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
export type ConvBlock =
|
|
352
|
+
| { type: 'text'; text: string }
|
|
353
|
+
| { type: 'image'; src: string }
|
|
354
|
+
| { type: 'file'; src: string; name: string; mimeType: string }
|
|
355
|
+
| { type: 'context'; label: string; text: string }
|
|
356
|
+
| { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
|
|
357
|
+
| { type: 'tool_result'; toolUseId: string; output: string }
|
|
358
|
+
| { type: 'thinking'; text: string }
|
|
359
|
+
| { type: 'duplex_result'; label?: string; tier?: string; text?: string }
|
|
360
|
+
| { type: 'summary'; text: string; compactedCount: number }
|
|
361
|
+
| { type: 'compact_marker'; summary: string; compactedCount: number };
|
|
362
|
+
|
|
363
|
+
export interface ConvMessage {
|
|
364
|
+
id: string;
|
|
365
|
+
// 'system' is written for out-of-band notices persisted into the thread (e.g. a playback
|
|
366
|
+
// interruption). History builders treat any non-'user' role as assistant-side.
|
|
367
|
+
role: 'user' | 'assistant' | 'system';
|
|
368
|
+
blocks: ConvBlock[];
|
|
369
|
+
channel?: string;
|
|
370
|
+
ts?: number;
|
|
371
|
+
senderName?: string;
|
|
372
|
+
/** Model that produced this assistant message (stamped from the engine's resolved model). */
|
|
373
|
+
model?: string;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const CONV_DIR = dataPath('conversations');
|
|
377
|
+
|
|
378
|
+
export function loadConversation(sessionId: string): ConvMessage[] {
|
|
379
|
+
const file = path.join(CONV_DIR, `${sessionId}.jsonl`);
|
|
380
|
+
if (!existsSync(file)) return [];
|
|
381
|
+
const messages: ConvMessage[] = [];
|
|
382
|
+
for (const line of readFileSync(file, 'utf-8').split('\n')) {
|
|
383
|
+
if (!line) continue;
|
|
384
|
+
try { messages.push(JSON.parse(line)); } catch {
|
|
385
|
+
console.warn(`[sessions] skipping bad JSONL line in ${sessionId.slice(0, 8)}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return messages;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function saveConversation(sessionId: string, messages: ConvMessage[]): void {
|
|
392
|
+
mkdirSync(CONV_DIR, { recursive: true });
|
|
393
|
+
const content = messages.length > 0
|
|
394
|
+
? messages.map((m) => JSON.stringify(m)).join('\n') + '\n'
|
|
395
|
+
: '';
|
|
396
|
+
writeFileSync(path.join(CONV_DIR, `${sessionId}.jsonl`), content);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function appendMessage(sessionId: string, message: ConvMessage): void {
|
|
400
|
+
if (!message.ts) message.ts = Date.now();
|
|
401
|
+
if (message.role === 'assistant' && !message.model) {
|
|
402
|
+
const model = liveModels.get(sessionId);
|
|
403
|
+
if (model) message.model = model;
|
|
404
|
+
}
|
|
405
|
+
mkdirSync(CONV_DIR, { recursive: true });
|
|
406
|
+
const file = path.join(CONV_DIR, `${sessionId}.jsonl`);
|
|
407
|
+
const line = JSON.stringify(message) + '\n';
|
|
408
|
+
appendFileSync(file, line);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── Live partial registry (in-memory, for real-time reads) ───────────────────
|
|
412
|
+
|
|
413
|
+
const livePartialCollectors = new Map<string, () => ConvBlock[]>();
|
|
414
|
+
|
|
415
|
+
export function registerLivePartial(sessionId: string, collector: () => ConvBlock[]): void {
|
|
416
|
+
livePartialCollectors.set(sessionId, collector);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function unregisterLivePartial(sessionId: string): void {
|
|
420
|
+
livePartialCollectors.delete(sessionId);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function readLivePartial(sessionId: string): ConvBlock[] | null {
|
|
424
|
+
const collector = livePartialCollectors.get(sessionId);
|
|
425
|
+
if (!collector) return null;
|
|
426
|
+
const blocks = collector();
|
|
427
|
+
return blocks.length ? blocks : null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Partial flush (survives crashes) ─────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
export function writePartial(sessionId: string, blocks: ConvBlock[]): void {
|
|
433
|
+
if (!blocks.length) return;
|
|
434
|
+
mkdirSync(CONV_DIR, { recursive: true });
|
|
435
|
+
writeFileSync(path.join(CONV_DIR, `${sessionId}.partial.json`), JSON.stringify({ blocks, ts: Date.now() }));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function readPartial(sessionId: string): ConvBlock[] | null {
|
|
439
|
+
const file = path.join(CONV_DIR, `${sessionId}.partial.json`);
|
|
440
|
+
if (!existsSync(file)) return null;
|
|
441
|
+
try {
|
|
442
|
+
const data = JSON.parse(readFileSync(file, 'utf-8'));
|
|
443
|
+
return data.blocks ?? null;
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function clearPartial(sessionId: string): void {
|
|
450
|
+
const file = path.join(CONV_DIR, `${sessionId}.partial.json`);
|
|
451
|
+
try { unlinkSync(file); } catch {}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Claude JSONL fallback ────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
export async function getSessionHistory(sessionId: string): Promise<any[]> {
|
|
457
|
+
const file = findSessionFile(sessionId);
|
|
458
|
+
if (!file) {
|
|
459
|
+
console.log(`[sessions] no JSONL file found for ${sessionId.slice(0, 8)}`);
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const content = await readFile(file, 'utf-8');
|
|
465
|
+
const messages: any[] = [];
|
|
466
|
+
for (const line of content.split('\n')) {
|
|
467
|
+
if (!line) continue;
|
|
468
|
+
const obj = JSON.parse(line);
|
|
469
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
470
|
+
messages.push(obj);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
console.log(`[sessions] loaded ${messages.length} messages for ${sessionId.slice(0, 8)} from ${path.basename(path.dirname(file))}`);
|
|
474
|
+
return messages;
|
|
475
|
+
} catch (err: any) {
|
|
476
|
+
console.warn(`[sessions] failed to read ${sessionId.slice(0, 8)}:`, err.message);
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── One-time backfill: set visibleTo/scope on legacy bot sessions ───────────
|
|
482
|
+
|
|
483
|
+
export function forkSession(sourceId: string, user: { uid: string; email: string; name?: string }, truncateAtIndex?: number): string | null {
|
|
484
|
+
const source = getSession(sourceId);
|
|
485
|
+
if (!source) return null;
|
|
486
|
+
|
|
487
|
+
const messages = loadConversation(sourceId);
|
|
488
|
+
if (!messages.length) return null;
|
|
489
|
+
|
|
490
|
+
const newId = crypto.randomUUID();
|
|
491
|
+
const forkedMessages = truncateAtIndex != null ? messages.slice(0, truncateAtIndex + 1) : messages;
|
|
492
|
+
saveConversation(newId, forkedMessages);
|
|
493
|
+
|
|
494
|
+
const sessions = loadIndex();
|
|
495
|
+
const now = Date.now();
|
|
496
|
+
sessions.unshift({
|
|
497
|
+
sessionId: newId,
|
|
498
|
+
title: `Fork: ${source.title}`,
|
|
499
|
+
userEmail: user.email,
|
|
500
|
+
userName: user.name || user.email.split('@')[0],
|
|
501
|
+
uid: user.uid,
|
|
502
|
+
createdAt: now,
|
|
503
|
+
lastModified: now,
|
|
504
|
+
forkedFrom: sourceId,
|
|
505
|
+
...(source.directives ? { directives: source.directives } : {}),
|
|
506
|
+
});
|
|
507
|
+
saveIndex(sessions);
|
|
508
|
+
|
|
509
|
+
return newId;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function backfillSessionVisibility(findContact: (opts: { name?: string }) => { emails: string[] } | null): void {
|
|
513
|
+
const sessions = loadIndex();
|
|
514
|
+
let patched = 0;
|
|
515
|
+
|
|
516
|
+
for (const s of sessions) {
|
|
517
|
+
if (s.visibleTo) continue;
|
|
518
|
+
|
|
519
|
+
if (s.uid === 'slack-bot') {
|
|
520
|
+
if (s.slackContext?.type === 'dm') {
|
|
521
|
+
s.scope = 'user';
|
|
522
|
+
if (s.slackContext.userName) {
|
|
523
|
+
const contact = findContact({ name: s.slackContext.userName });
|
|
524
|
+
if (contact?.emails.length) {
|
|
525
|
+
s.visibleTo = contact.emails.map((e) => e.toLowerCase());
|
|
526
|
+
patched++;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} else if (!s.scope || s.scope !== 'system') {
|
|
530
|
+
s.scope = 'system';
|
|
531
|
+
patched++;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (s.uid === 'gmail-bot') {
|
|
536
|
+
const conv = loadConversation(s.sessionId);
|
|
537
|
+
const ctxBlock = conv[0]?.blocks?.find((b: any) => b.type === 'context');
|
|
538
|
+
if (ctxBlock && 'text' in ctxBlock) {
|
|
539
|
+
const emails: string[] = [];
|
|
540
|
+
for (const line of (ctxBlock as any).text.split('\n')) {
|
|
541
|
+
if (/^(From|To|Cc):/i.test(line)) {
|
|
542
|
+
for (const m of line.matchAll(/[\w.+-]+@[\w.-]+\.\w+/g)) emails.push(m[0].toLowerCase());
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const botEmail = (process.env.GMAIL_USER_EMAIL || '').toLowerCase();
|
|
546
|
+
const filtered = [...new Set(emails)].filter((e) => e !== botEmail);
|
|
547
|
+
if (filtered.length) {
|
|
548
|
+
s.visibleTo = filtered;
|
|
549
|
+
patched++;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (patched > 0) {
|
|
556
|
+
saveIndex(sessions);
|
|
557
|
+
console.log(`[sessions] backfill: patched visibility on ${patched} sessions`);
|
|
558
|
+
}
|
|
559
|
+
}
|