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,112 @@
|
|
|
1
|
+
// Slack-medium implementation of the AskUserQuestion flow: render the agent's
|
|
2
|
+
// questions as a Block Kit form (radio_buttons / checkboxes + Submit button) and
|
|
3
|
+
// resolve the pending turn when the user clicks Submit via Slack interactivity.
|
|
4
|
+
import { slackPost } from './api.ts';
|
|
5
|
+
import { handlePollVote, handlePollClose } from '../polls.ts';
|
|
6
|
+
import type { QuestionHandler, AskQuestion, QuestionAnswers } from '../claude.ts';
|
|
7
|
+
|
|
8
|
+
const PREFIX = '[slack-q]';
|
|
9
|
+
const TTL_MS = 15 * 60_000; // questions expire after 15min → agent self-decides
|
|
10
|
+
const SLACK_MAX = 75; // Slack option label/value max length
|
|
11
|
+
|
|
12
|
+
type Ctx = { channel: string; threadTs?: string; useUserToken?: boolean };
|
|
13
|
+
type Pending = Ctx & {
|
|
14
|
+
resolve: (answers: QuestionAnswers | null) => void;
|
|
15
|
+
questions: AskQuestion[];
|
|
16
|
+
messageTs?: string;
|
|
17
|
+
timer: ReturnType<typeof setTimeout>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const pending = new Map<string, Pending>();
|
|
21
|
+
|
|
22
|
+
const trunc = (s: string, n = SLACK_MAX) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
|
23
|
+
|
|
24
|
+
function buildBlocks(id: string, questions: AskQuestion[]): unknown[] {
|
|
25
|
+
const blocks: unknown[] = [];
|
|
26
|
+
questions.forEach((q, i) => {
|
|
27
|
+
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*${q.question}*` } });
|
|
28
|
+
const options = q.options.map((o) => ({
|
|
29
|
+
text: { type: 'plain_text', text: trunc(o.label) },
|
|
30
|
+
value: trunc(o.label),
|
|
31
|
+
...(o.description ? { description: { type: 'plain_text', text: trunc(o.description) } } : {}),
|
|
32
|
+
}));
|
|
33
|
+
blocks.push({
|
|
34
|
+
type: 'actions',
|
|
35
|
+
block_id: `q${i}`,
|
|
36
|
+
elements: [{ type: q.multiSelect ? 'checkboxes' : 'radio_buttons', action_id: `q${i}`, options }],
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
blocks.push({
|
|
40
|
+
type: 'actions',
|
|
41
|
+
elements: [{ type: 'button', action_id: `submit:${id}`, style: 'primary', value: id, text: { type: 'plain_text', text: 'Submit' } }],
|
|
42
|
+
});
|
|
43
|
+
return blocks;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build a QuestionHandler bound to a Slack channel/thread for the current turn. */
|
|
47
|
+
export function makeSlackQuestionHandler(ctx: Ctx): QuestionHandler {
|
|
48
|
+
return async (id, questions) => {
|
|
49
|
+
const res = await slackPost(
|
|
50
|
+
'chat.postMessage',
|
|
51
|
+
{ channel: ctx.channel, thread_ts: ctx.threadTs, text: 'I have a few questions for you:', blocks: buildBlocks(id, questions) },
|
|
52
|
+
ctx.useUserToken,
|
|
53
|
+
).catch((err) => { console.error(`${PREFIX} post failed:`, (err as Error)?.message); return null; });
|
|
54
|
+
if (!res?.ok) return null;
|
|
55
|
+
console.log(`${PREFIX} posted question ${id} (${questions.length}q) to ${ctx.channel}`);
|
|
56
|
+
return new Promise<QuestionAnswers | null>((resolve) => {
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
if (pending.delete(id)) { console.log(`${PREFIX} ${id} timed out`); resolve(null); }
|
|
59
|
+
}, TTL_MS);
|
|
60
|
+
pending.set(id, { ...ctx, resolve, questions, messageTs: res.ts, timer });
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseAnswers(state: any, questions: AskQuestion[]): QuestionAnswers {
|
|
66
|
+
const answers: QuestionAnswers = {};
|
|
67
|
+
questions.forEach((q, i) => {
|
|
68
|
+
const block = state?.[`q${i}`]?.[`q${i}`];
|
|
69
|
+
if (!block) return;
|
|
70
|
+
if (q.multiSelect) {
|
|
71
|
+
const labels = (block.selected_options ?? []).map((o: any) => o.value);
|
|
72
|
+
if (labels.length) answers[q.question] = labels;
|
|
73
|
+
} else if (block.selected_option) {
|
|
74
|
+
answers[q.question] = block.selected_option.value;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return answers;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Handle a Slack interactivity payload (block_actions). Returns true if it
|
|
82
|
+
* resolved a pending question. Selection changes (non-submit actions) are ignored.
|
|
83
|
+
*/
|
|
84
|
+
export async function handleSlackInteraction(payload: any): Promise<boolean> {
|
|
85
|
+
if (payload?.type !== 'block_actions') return false;
|
|
86
|
+
const userId = payload.user?.id as string | undefined;
|
|
87
|
+
|
|
88
|
+
// Poll / directed-question votes — action_id `poll:vote:<id>:<optIdx>` or `poll:close:<id>`.
|
|
89
|
+
const pollAction = (payload.actions ?? []).find((a: any) => typeof a.action_id === 'string' && a.action_id.startsWith('poll:'));
|
|
90
|
+
if (pollAction) {
|
|
91
|
+
const [, verb, pollId, optIdx] = (pollAction.action_id as string).split(':');
|
|
92
|
+
if (verb === 'close') await handlePollClose(pollId);
|
|
93
|
+
else if (verb === 'vote' && userId) await handlePollVote(pollId, userId, Number(optIdx));
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const submit = (payload.actions ?? []).find((a: any) => typeof a.action_id === 'string' && a.action_id.startsWith('submit:'));
|
|
98
|
+
if (!submit) return false;
|
|
99
|
+
const id = submit.value as string;
|
|
100
|
+
const p = pending.get(id);
|
|
101
|
+
if (!p) return false;
|
|
102
|
+
pending.delete(id);
|
|
103
|
+
clearTimeout(p.timer);
|
|
104
|
+
const answers = parseAnswers(payload.state?.values, p.questions);
|
|
105
|
+
if (p.messageTs) {
|
|
106
|
+
const summary = Object.entries(answers).map(([q, a]) => `• *${q}* — ${Array.isArray(a) ? a.join(', ') : a}`).join('\n') || '_(no selection)_';
|
|
107
|
+
await slackPost('chat.update', { channel: p.channel, ts: p.messageTs, text: '✅ Got your answers.', blocks: [{ type: 'section', text: { type: 'mrkdwn', text: `✅ Got your answers:\n${summary}` } }] }, p.useUserToken).catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
console.log(`${PREFIX} resolved question ${id} (${Object.keys(answers).length} answered)`);
|
|
110
|
+
p.resolve(Object.keys(answers).length ? answers : null);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dataPath } from '../paths.ts';
|
|
3
|
+
import { upsertSession } from '../sessions.ts';
|
|
4
|
+
|
|
5
|
+
interface SlackSession {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
channel: string;
|
|
8
|
+
lastActivity: number;
|
|
9
|
+
lastMessageTs?: string;
|
|
10
|
+
useUserToken?: boolean;
|
|
11
|
+
flat?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAPPING_FILE = dataPath('slack-sessions.json');
|
|
15
|
+
const PROACTIVE_FILE = dataPath('slack-proactive.json');
|
|
16
|
+
const SLACK_BOT_EMAIL = 'slack-bot@shraga.local';
|
|
17
|
+
/**
|
|
18
|
+
* Placeholder emails that mean "no real user" — NOT an address to attribute a turn to.
|
|
19
|
+
* `slack-bot@unclaw.local` is the legacy value and is still persisted in existing
|
|
20
|
+
* `slack-sessions.json` files, so it must keep being recognised.
|
|
21
|
+
*/
|
|
22
|
+
const SLACK_BOT_EMAILS: readonly string[] = [SLACK_BOT_EMAIL, 'slack-bot@unclaw.local'];
|
|
23
|
+
|
|
24
|
+
export function isSlackBotPlaceholderEmail(email: string | undefined): boolean {
|
|
25
|
+
return !!email && SLACK_BOT_EMAILS.includes(email);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SLACK_USER = { uid: 'slack-bot', email: SLACK_BOT_EMAIL, name: 'Slack Bot' };
|
|
29
|
+
|
|
30
|
+
let mapping: Record<string, SlackSession> = {};
|
|
31
|
+
|
|
32
|
+
function loadMapping() {
|
|
33
|
+
if (existsSync(MAPPING_FILE)) {
|
|
34
|
+
try { mapping = JSON.parse(readFileSync(MAPPING_FILE, 'utf-8')); } catch { mapping = {}; }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveMapping() {
|
|
39
|
+
writeFileSync(MAPPING_FILE, JSON.stringify(mapping, null, 2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function threadKey(channel: string, threadTs: string): string {
|
|
43
|
+
return `${channel}:${threadTs}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Register an additional thread key pointing to an existing session (e.g. bot reply ts). */
|
|
47
|
+
export function registerThreadAlias(channel: string, threadTs: string, sessionId: string): void {
|
|
48
|
+
loadMapping();
|
|
49
|
+
const key = threadKey(channel, threadTs);
|
|
50
|
+
if (mapping[key]) return;
|
|
51
|
+
mapping[key] = { sessionId, channel, lastActivity: Date.now() };
|
|
52
|
+
saveMapping();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getOrCreateSession(channel: string, threadTs: string, firstPrompt: string, flat?: boolean, scope: 'system' | 'user' = 'system', user?: { uid: string; email: string; name?: string }): { sessionId: string; isNew: boolean } {
|
|
56
|
+
loadMapping();
|
|
57
|
+
const key = threadKey(channel, threadTs);
|
|
58
|
+
if (mapping[key]) {
|
|
59
|
+
mapping[key].lastActivity = Date.now();
|
|
60
|
+
saveMapping();
|
|
61
|
+
return { sessionId: mapping[key].sessionId, isNew: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sessionId = crypto.randomUUID();
|
|
65
|
+
// Attribute the session to the resolved human (for nightly reconcile user-scope writes).
|
|
66
|
+
// Unknown Slack users fall through to the synthetic SLACK_USER — runs, but won't mis-attribute.
|
|
67
|
+
upsertSession(sessionId, firstPrompt, user ?? SLACK_USER, scope);
|
|
68
|
+
mapping[key] = { sessionId, channel, lastActivity: Date.now(), ...(flat ? { flat: true } : {}) };
|
|
69
|
+
saveMapping();
|
|
70
|
+
return { sessionId, isNew: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True if an agent session already exists for this thread (read-only; does not create). */
|
|
74
|
+
export function hasSessionForThread(channel: string, threadTs: string): boolean {
|
|
75
|
+
loadMapping();
|
|
76
|
+
return !!mapping[threadKey(channel, threadTs)];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function setLastMessageTs(channel: string, threadTs: string, ts: string): void {
|
|
80
|
+
loadMapping();
|
|
81
|
+
const key = threadKey(channel, threadTs);
|
|
82
|
+
if (mapping[key]) {
|
|
83
|
+
mapping[key].lastMessageTs = ts;
|
|
84
|
+
saveMapping();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function setFlat(channel: string, threadTs: string, flat: boolean): void {
|
|
89
|
+
loadMapping();
|
|
90
|
+
const key = threadKey(channel, threadTs);
|
|
91
|
+
if (mapping[key]) {
|
|
92
|
+
mapping[key].flat = flat;
|
|
93
|
+
saveMapping();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function setUseUserToken(channel: string, threadTs: string, useUserToken: boolean): void {
|
|
98
|
+
loadMapping();
|
|
99
|
+
const key = threadKey(channel, threadTs);
|
|
100
|
+
if (mapping[key]) {
|
|
101
|
+
mapping[key].useUserToken = useUserToken;
|
|
102
|
+
saveMapping();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Proactive message registry ---
|
|
107
|
+
interface ProactiveMessage { sessionId: string; sessionTitle: string; sentAt: number; }
|
|
108
|
+
let proactiveMap: Record<string, ProactiveMessage> = {};
|
|
109
|
+
|
|
110
|
+
function loadProactive() {
|
|
111
|
+
if (existsSync(PROACTIVE_FILE)) {
|
|
112
|
+
try { proactiveMap = JSON.parse(readFileSync(PROACTIVE_FILE, 'utf-8')); } catch { proactiveMap = {}; }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function saveProactive() { writeFileSync(PROACTIVE_FILE, JSON.stringify(proactiveMap, null, 2)); }
|
|
116
|
+
|
|
117
|
+
export function registerProactiveMessage(channel: string, ts: string, sessionId: string, sessionTitle: string): void {
|
|
118
|
+
loadProactive();
|
|
119
|
+
const key = threadKey(channel, ts);
|
|
120
|
+
proactiveMap[key] = { sessionId, sessionTitle, sentAt: Date.now() };
|
|
121
|
+
saveProactive();
|
|
122
|
+
console.log(`[slack] Registered proactive message: ${key} → session ${sessionId}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getProactiveOrigin(channel: string, ts: string): ProactiveMessage | null {
|
|
126
|
+
loadProactive();
|
|
127
|
+
return proactiveMap[threadKey(channel, ts)] || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function findSlackSessionBySessionId(sessionId: string): { channel: string; threadTs: string; lastMessageTs?: string; useUserToken?: boolean; flat?: boolean } | null {
|
|
131
|
+
loadMapping();
|
|
132
|
+
for (const [key, entry] of Object.entries(mapping)) {
|
|
133
|
+
if (entry.sessionId === sessionId) {
|
|
134
|
+
const sepIdx = key.indexOf(':');
|
|
135
|
+
return { channel: key.slice(0, sepIdx), threadTs: key.slice(sepIdx + 1), lastMessageTs: entry.lastMessageTs, useUserToken: entry.useUserToken, flat: entry.flat };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Shared machine-stats sampler: ONE interval samples the host (CPU/mem/load) for the
|
|
2
|
+
// whole server and broadcasts each point to all clients. Clients never poll the box —
|
|
3
|
+
// they seed from getStats() and then receive live points over WS. N users = 1 sampler.
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export interface StatSample {
|
|
12
|
+
t: number; // epoch ms
|
|
13
|
+
cpu: number; // 0-100, host CPU utilization since last sample
|
|
14
|
+
mem: number; // 0-100, used / total memory
|
|
15
|
+
load: number; // 1-min load average
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class StatsSamplerOptions {
|
|
19
|
+
intervalMs = 5000;
|
|
20
|
+
window = 120; // ring-buffer length (120 × 5s = 10 min trend)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class StatsSampler {
|
|
24
|
+
public options: StatsSamplerOptions;
|
|
25
|
+
private buffer: StatSample[] = [];
|
|
26
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
27
|
+
private prevCpu = cpuTotals();
|
|
28
|
+
// Cached memory %, refreshed asynchronously each tick. NEVER read the OS memory synchronously in the
|
|
29
|
+
// sample path: on macOS that meant `execSync('memory_pressure')`, which blocks the event loop for
|
|
30
|
+
// ~1-2s (longer under load) every 5s — stalling /api/version long enough to trip the health watchdog
|
|
31
|
+
// into false-restarting a live server. Seed from the cheap os.freemem fallback until the first refresh.
|
|
32
|
+
private memPct = 100 * (1 - os.freemem() / os.totalmem());
|
|
33
|
+
|
|
34
|
+
public constructor(options?: Partial<StatsSamplerOptions>) {
|
|
35
|
+
this.options = { ...new StatsSamplerOptions(), ...options };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
start(broadcast: (data: object) => void) {
|
|
39
|
+
if (this.timer) return; // singleton — already running
|
|
40
|
+
void this.refreshMem(); // prime the cache; each tick refreshes it async for the NEXT sample
|
|
41
|
+
this.timer = setInterval(() => {
|
|
42
|
+
void this.refreshMem(); // async, non-blocking — updates this.memPct without stalling the loop
|
|
43
|
+
const sample = this.sample();
|
|
44
|
+
this.buffer.push(sample);
|
|
45
|
+
if (this.buffer.length > this.options.window) this.buffer.shift();
|
|
46
|
+
broadcast({ type: 'stats', sample });
|
|
47
|
+
}, this.options.intervalMs);
|
|
48
|
+
if (typeof this.timer.unref === 'function') this.timer.unref();
|
|
49
|
+
console.log(`[stats] sampling host every ${this.options.intervalMs}ms (window ${this.options.window})`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stop() {
|
|
53
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getStats(): StatSample[] {
|
|
57
|
+
return this.buffer;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private sample(): StatSample {
|
|
61
|
+
const now = cpuTotals();
|
|
62
|
+
const idleDelta = now.idle - this.prevCpu.idle;
|
|
63
|
+
const totalDelta = now.total - this.prevCpu.total;
|
|
64
|
+
this.prevCpu = now;
|
|
65
|
+
const cpu = totalDelta > 0 ? Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta))) : 0;
|
|
66
|
+
return {
|
|
67
|
+
t: Date.now(),
|
|
68
|
+
cpu: Math.round(cpu),
|
|
69
|
+
mem: Math.round(this.memPct), // cached; refreshed async each tick (never blocks the loop)
|
|
70
|
+
load: Math.round(os.loadavg()[0] * 100) / 100,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Refresh the cached memory %. os.freemem() counts reclaimable memory (page cache on Linux,
|
|
75
|
+
// inactive/purgeable pages on macOS) as "used", so it reads ~95-99% even when healthy — use each OS's
|
|
76
|
+
// notion of *available* (free + reclaimable) instead. Async + bounded so it can NEVER stall the loop.
|
|
77
|
+
private async refreshMem(): Promise<void> {
|
|
78
|
+
try {
|
|
79
|
+
if (process.platform === 'linux') {
|
|
80
|
+
const info = readFileSync('/proc/meminfo', 'utf8'); // tiny virtual file — negligible read
|
|
81
|
+
const total = +(info.match(/MemTotal:\s+(\d+)/)?.[1] ?? 0);
|
|
82
|
+
const avail = info.match(/MemAvailable:\s+(\d+)/)?.[1];
|
|
83
|
+
if (total > 0 && avail != null) { this.memPct = 100 * (1 - +avail / total); return; }
|
|
84
|
+
} else if (process.platform === 'darwin') {
|
|
85
|
+
// macOS pressure-aware "free percentage". Was execSync — which blocked the event loop for
|
|
86
|
+
// seconds every tick and tripped the health watchdog. Async with a hard timeout; killed and
|
|
87
|
+
// fell back if it stalls, so a slow/hung `memory_pressure` never touches the loop.
|
|
88
|
+
const { stdout } = await execFileAsync('memory_pressure', [], { encoding: 'utf8', timeout: 3000 });
|
|
89
|
+
const freePct = stdout.match(/free percentage:\s+(\d+)%/)?.[1];
|
|
90
|
+
if (freePct != null) { this.memPct = 100 - +freePct; return; }
|
|
91
|
+
}
|
|
92
|
+
} catch { /* fall through to os.freemem */ }
|
|
93
|
+
this.memPct = 100 * (1 - os.freemem() / os.totalmem());
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cpuTotals() {
|
|
98
|
+
let idle = 0, total = 0;
|
|
99
|
+
for (const c of os.cpus()) {
|
|
100
|
+
for (const v of Object.values(c.times)) total += v;
|
|
101
|
+
idle += c.times.idle;
|
|
102
|
+
}
|
|
103
|
+
return { idle, total };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const statsSampler = new StatsSampler();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { loadAgents } from './agents.ts';
|
|
2
|
+
import { runTextQuery } from './sdk-utils.ts';
|
|
3
|
+
|
|
4
|
+
export async function summarizeText(text: string, instruction: string): Promise<string> {
|
|
5
|
+
const agent = loadAgents()['summarizer'];
|
|
6
|
+
return runTextQuery({
|
|
7
|
+
prompt: `${instruction}\n\n${text}`,
|
|
8
|
+
model: agent?.model || 'haiku',
|
|
9
|
+
systemPrompt: agent?.prompt || 'You are a concise summarizer. Output only the summary, nothing else.',
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Turn-context seam — per-turn prompt contributions from optional add-ons.
|
|
2
|
+
//
|
|
3
|
+
// The core builds a turn's prompt and knows nothing about what an add-on might want to say about the
|
|
4
|
+
// user's current situation. A contributor returns a short block that is prepended to THIS turn's
|
|
5
|
+
// prompt only — never the cacheable contextBlock, never the persisted transcript.
|
|
6
|
+
//
|
|
7
|
+
// Sibling of the `features.ts` seam and shaped like it: typed, optional, registered before startup,
|
|
8
|
+
// a no-op when nothing is registered (the core registers NOTHING here). The core concatenates
|
|
9
|
+
// whatever comes back and interprets none of it.
|
|
10
|
+
|
|
11
|
+
/** What the core knows about the turn. `hints` is the opaque client-supplied per-send bag (the
|
|
12
|
+
* client's `setSendOptions` slot) — the core never interprets it; a contributor reads its own keys. */
|
|
13
|
+
export interface TurnContextInput {
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
uid?: string;
|
|
16
|
+
hints?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Return a prompt block for this turn, or undefined/'' to contribute nothing. */
|
|
20
|
+
export type TurnContextContributor = (input: TurnContextInput) => string | undefined;
|
|
21
|
+
|
|
22
|
+
const contributors = new Map<string, TurnContextContributor>();
|
|
23
|
+
|
|
24
|
+
/** Register a per-turn prompt contributor. Called by an add-on before startup. Idempotent by name. */
|
|
25
|
+
export function registerTurnContext(name: string, contribute: TurnContextContributor): void {
|
|
26
|
+
if (contributors.has(name)) return;
|
|
27
|
+
contributors.set(name, contribute);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collect every contributor's block for this turn, joined by a blank line. Empty when none is
|
|
32
|
+
* registered — which is the core's own state, so the core's prompt is byte-identical to before.
|
|
33
|
+
* A throwing contributor is contained (logged, others still contribute): a broken add-on must not
|
|
34
|
+
* take down a turn.
|
|
35
|
+
*/
|
|
36
|
+
export function collectTurnContext(input: TurnContextInput): string {
|
|
37
|
+
const blocks: string[] = [];
|
|
38
|
+
for (const [name, contribute] of contributors) {
|
|
39
|
+
try {
|
|
40
|
+
const block = contribute(input)?.trim();
|
|
41
|
+
if (block) blocks.push(block);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(`[turn-context] ${name} failed:`, (err as Error)?.message ?? err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return blocks.join('\n\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sanitize a client-supplied hint value before a contributor interpolates it into the model prompt.
|
|
51
|
+
* Strips control chars/newlines and caps length, so a crafted value can't forge a second block or
|
|
52
|
+
* inject instructions. Generic (knows no add-on shape) and lives here because EVERY contributor
|
|
53
|
+
* interpolates untrusted `hints` — keeping it on the seam makes the requirement discoverable.
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeHintField(value: string, max: number): string {
|
|
56
|
+
return Array.from(value)
|
|
57
|
+
.filter((c) => { const n = c.charCodeAt(0); return n >= 32 && n !== 127; })
|
|
58
|
+
.join('')
|
|
59
|
+
.slice(0, max)
|
|
60
|
+
.trim();
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Back-compat shim for the pre-de-brand module name. NOT a second source of truth —
|
|
3
|
+
* it re-exports `shraga-config.ts` and must never grow logic or its own types.
|
|
4
|
+
*
|
|
5
|
+
* Why it exists: a deployment's data config is written by the operator and value-imports
|
|
6
|
+
* this path (`import { defineConfig } from '../src/server/unclaw-config.ts'`), so the name
|
|
7
|
+
* is part of our public surface — renaming the module broke every deployment still carrying
|
|
8
|
+
* a legacy `data/unclaw.config.ts`, and the loader swallows that failure into an empty config.
|
|
9
|
+
*
|
|
10
|
+
* Same back-compat contract the loader already honours on the filename side
|
|
11
|
+
* (`CONFIG_FILENAMES = ['shraga.config.ts', 'unclaw.config.ts']`, new name preferred,
|
|
12
|
+
* old name still accepted) — this is that contract on the module side.
|
|
13
|
+
*
|
|
14
|
+
* Retire both together, once no deployment ships a legacy config.
|
|
15
|
+
*/
|
|
16
|
+
export * from './shraga-config.ts';
|
|
17
|
+
|
|
18
|
+
/** Legacy name for {@link ShragaConfig} — the interface a legacy data config may annotate with. */
|
|
19
|
+
export type { ShragaConfig as UnclawConfig } from './shraga-config.ts';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dataPath } from './paths.ts';
|
|
3
|
+
import { getSession } from './sessions.ts';
|
|
4
|
+
|
|
5
|
+
export interface UnreadEntry {
|
|
6
|
+
count: number;
|
|
7
|
+
preview: string;
|
|
8
|
+
since: number;
|
|
9
|
+
lastAt: number;
|
|
10
|
+
source: 'response' | 'proactive' | 'schedule';
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UserUnreads {
|
|
15
|
+
sessions: Record<string, UnreadEntry>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const UNREAD_DIR = dataPath('unread');
|
|
19
|
+
|
|
20
|
+
function filePath(uid: string): string {
|
|
21
|
+
return `${UNREAD_DIR}/${uid}.json`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function load(uid: string): UserUnreads {
|
|
25
|
+
const file = filePath(uid);
|
|
26
|
+
if (!existsSync(file)) return { sessions: {} };
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return { sessions: {} };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function save(uid: string, data: UserUnreads): void {
|
|
35
|
+
mkdirSync(UNREAD_DIR, { recursive: true });
|
|
36
|
+
writeFileSync(filePath(uid), JSON.stringify(data, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function addUnread(
|
|
40
|
+
uid: string,
|
|
41
|
+
sessionId: string,
|
|
42
|
+
preview: string,
|
|
43
|
+
source: UnreadEntry['source'],
|
|
44
|
+
title?: string,
|
|
45
|
+
): UnreadEntry {
|
|
46
|
+
const data = load(uid);
|
|
47
|
+
const existing = data.sessions[sessionId];
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const entry: UnreadEntry = {
|
|
50
|
+
count: (existing?.count ?? 0) + 1,
|
|
51
|
+
preview,
|
|
52
|
+
since: existing?.since ?? now,
|
|
53
|
+
lastAt: now,
|
|
54
|
+
source,
|
|
55
|
+
...(title ? { title } : existing?.title ? { title: existing.title } : {}),
|
|
56
|
+
};
|
|
57
|
+
data.sessions[sessionId] = entry;
|
|
58
|
+
save(uid, data);
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function markRead(uid: string, sessionId: string): void {
|
|
63
|
+
const data = load(uid);
|
|
64
|
+
if (!data.sessions[sessionId]) return;
|
|
65
|
+
delete data.sessions[sessionId];
|
|
66
|
+
save(uid, data);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getUnreads(uid: string): UserUnreads {
|
|
70
|
+
const data = load(uid);
|
|
71
|
+
const sids = Object.keys(data.sessions);
|
|
72
|
+
const orphans = sids.filter(sid => !getSession(sid));
|
|
73
|
+
if (orphans.length > 0) {
|
|
74
|
+
for (const sid of orphans) delete data.sessions[sid];
|
|
75
|
+
save(uid, data);
|
|
76
|
+
}
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdirSync, existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { WORKSPACE_DIR } from './workspace.ts';
|
|
4
|
+
import { injectFile } from './file-inject.ts';
|
|
5
|
+
import type { Contact } from './contacts.ts';
|
|
6
|
+
|
|
7
|
+
const STUB = (name: string) =>
|
|
8
|
+
`# ${name}\n\n## Summary\n\n_No learnings yet._\n\n## Corrections\n\n## How They Operate\n\n## Their Taste\n\n## Current Focus\n`;
|
|
9
|
+
|
|
10
|
+
const SYSTEM_EMAIL_RE = /^(postmaster|mailer-daemon|noreply|no-reply|notifications?|drive-shares-dm-noreply)@/i;
|
|
11
|
+
|
|
12
|
+
function isHumanContact(contact: Contact): boolean {
|
|
13
|
+
if (!contact.emails.length && !contact.slackIds.length) return false;
|
|
14
|
+
if (contact.emails.length && contact.emails.every(e => SYSTEM_EMAIL_RE.test(e))) return false;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ensureUserDir(contact: Contact): string | null {
|
|
19
|
+
if (!isHumanContact(contact)) return null;
|
|
20
|
+
const dir = path.join(WORKSPACE_DIR, 'users', contact.id);
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
const contextFile = path.join(dir, 'user-context.md');
|
|
23
|
+
if (!existsSync(contextFile)) writeFileSync(contextFile, STUB(contact.name), 'utf-8');
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getUserContextBlock(contact: Contact | null): string {
|
|
28
|
+
if (!contact) return '';
|
|
29
|
+
const dir = ensureUserDir(contact);
|
|
30
|
+
if (!dir) return '';
|
|
31
|
+
const filePath = path.join(dir, 'user-context.md');
|
|
32
|
+
return injectFile(filePath, { label: 'user-context', maxChars: 3000 });
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readdirSync, lstatSync, readlinkSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { $ } from 'bun';
|
|
4
|
+
|
|
5
|
+
const TAG = '[vendor-sync]';
|
|
6
|
+
const VENDOR_DIR = resolve(import.meta.dir, '../../vendor');
|
|
7
|
+
|
|
8
|
+
export async function syncVendorRepos(): Promise<void> {
|
|
9
|
+
let entries: string[];
|
|
10
|
+
try {
|
|
11
|
+
entries = readdirSync(VENDOR_DIR);
|
|
12
|
+
} catch {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const dirs = entries.filter(e => {
|
|
17
|
+
const full = join(VENDOR_DIR, e);
|
|
18
|
+
try {
|
|
19
|
+
const stat = lstatSync(full);
|
|
20
|
+
if (!stat.isSymbolicLink()) return false;
|
|
21
|
+
const target = resolve(VENDOR_DIR, readlinkSync(full));
|
|
22
|
+
return lstatSync(target).isDirectory();
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!dirs.length) return;
|
|
27
|
+
|
|
28
|
+
const results = await Promise.allSettled(
|
|
29
|
+
dirs.map(async name => {
|
|
30
|
+
const target = resolve(VENDOR_DIR, readlinkSync(join(VENDOR_DIR, name)));
|
|
31
|
+
const gitDir = join(target, '.git');
|
|
32
|
+
try { lstatSync(gitDir); } catch { return; }
|
|
33
|
+
// skip branches with no upstream (e.g. local feature branches) — pull would always fail
|
|
34
|
+
const upstream = await $`git -C ${target} rev-parse --abbrev-ref @{u}`.nothrow().quiet();
|
|
35
|
+
if (upstream.exitCode !== 0) {
|
|
36
|
+
const branch = (await $`git -C ${target} branch --show-current`.nothrow().quiet()).stdout.toString().trim();
|
|
37
|
+
console.log(`${TAG} ${name}: skipped (branch '${branch}' has no upstream)`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const { stdout, exitCode } = await $`git -C ${target} pull --ff-only 2>&1`.nothrow().quiet();
|
|
41
|
+
const out = stdout.toString().trim();
|
|
42
|
+
if (exitCode !== 0) {
|
|
43
|
+
console.warn(`${TAG} ${name}: pull failed — ${out}`);
|
|
44
|
+
} else if (!out.includes('Already up to date')) {
|
|
45
|
+
console.log(`${TAG} ${name}: ${out.split('\n').pop()}`);
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const failed = results.filter(r => r.status === 'rejected').length;
|
|
51
|
+
if (failed) console.warn(`${TAG} ${failed} repo(s) failed to sync`);
|
|
52
|
+
}
|