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,167 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DATA_DIR } from './paths.ts';
4
+ import type { McpServerConfig, McpConfig, McpHttpServerConfig } from './mcp.ts';
5
+
6
+ /** Shorthand for vendor-dir MCPs (auto-resolves command/args from vendor/{name}) */
7
+ export interface McpShorthandEntry {
8
+ dir?: string;
9
+ command?: string;
10
+ args?: string[];
11
+ env?: string[];
12
+ }
13
+
14
+ /** Full MCP config (command, args, env with values) — for non-vendor MCPs */
15
+ export interface McpFullEntry {
16
+ command: string;
17
+ args?: string[];
18
+ env?: Record<string, string>;
19
+ enabled?: boolean;
20
+ }
21
+
22
+ /** HTTP MCP config — persistent sidecar, not spawned per-query */
23
+ export interface McpHttpEntry {
24
+ type: 'http';
25
+ url: string;
26
+ headers?: Record<string, string>;
27
+ enabled?: boolean;
28
+ /** Vendor dir to auto-start as HTTP sidecar (resolved to vendor/{dir}) */
29
+ dir?: string;
30
+ /** Port for the sidecar process */
31
+ port?: number;
32
+ }
33
+
34
+ export type McpEntry = McpShorthandEntry | McpFullEntry | McpHttpEntry;
35
+
36
+ function isHttpEntry(entry: McpEntry): entry is McpHttpEntry {
37
+ return (entry as any).type === 'http';
38
+ }
39
+
40
+ function isFullEntry(entry: McpEntry): entry is McpFullEntry {
41
+ return 'command' in entry && !Array.isArray((entry as any).env) && !isHttpEntry(entry);
42
+ }
43
+
44
+ export interface AgentSettings {
45
+ model?: string;
46
+ engine?: string;
47
+ systemPrompt?: string;
48
+ allowedTools?: string[];
49
+ permissionMode?: string;
50
+ maxTurns?: number;
51
+ skillDiscovery?: boolean;
52
+ thinking?: 'adaptive' | 'enabled' | 'disabled';
53
+ effort?: 'low' | 'medium' | 'high' | 'max';
54
+ }
55
+
56
+ export interface ShragaConfig {
57
+ /** @deprecated Use `mcps` instead */
58
+ vendorMcps?: Record<string, McpShorthandEntry>;
59
+ mcps?: Record<string, McpEntry>;
60
+ }
61
+
62
+ export interface HttpSidecarSpec {
63
+ name: string;
64
+ dir: string;
65
+ port: number;
66
+ url: string;
67
+ }
68
+
69
+ export function defineConfig(config: ShragaConfig): ShragaConfig {
70
+ return config;
71
+ }
72
+
73
+ const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
74
+
75
+ /**
76
+ * Config filenames, in precedence order. `shraga.config.ts` is canonical; `unclaw.config.ts` is
77
+ * the legacy name kept for back-compat — existing deployments have that file in their data dir,
78
+ * and dropping it would silently fall back to an empty config (losing every global MCP).
79
+ * Seeding is guarded on BOTH names (see seed.ts) so a fresh canonical file can never shadow a
80
+ * populated legacy one.
81
+ */
82
+ export const CONFIG_FILENAMES = ['shraga.config.ts', 'unclaw.config.ts'] as const;
83
+
84
+ /** First config file that exists in the data dir, or null when none is present. */
85
+ export function resolveConfigPath(): string | null {
86
+ for (const name of CONFIG_FILENAMES) {
87
+ const p = path.join(DATA_DIR, name);
88
+ if (existsSync(p)) return p;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ let _cached: ShragaConfig | null = null;
94
+
95
+ export async function loadShragaConfig(): Promise<ShragaConfig> {
96
+ if (_cached) return _cached;
97
+ const configPath = resolveConfigPath();
98
+ if (!configPath) {
99
+ _cached = {};
100
+ return _cached;
101
+ }
102
+ try {
103
+ const mod = await import(configPath);
104
+ _cached = mod.default ?? {};
105
+ } catch (e) {
106
+ console.error(`[config] failed to load ${path.basename(configPath)}:`, e instanceof Error ? e.message : String(e));
107
+ _cached = {};
108
+ }
109
+ // Every path above assigns _cached a non-null value; `?? {}` satisfies the type
110
+ // without changing behavior (mirrors getShragaConfigSync below).
111
+ return _cached ?? {};
112
+ }
113
+
114
+ export function getShragaConfigSync(): ShragaConfig {
115
+ if (!_cached) console.warn('[config] getShragaConfigSync called before loadShragaConfig — returning empty config');
116
+ return _cached ?? {};
117
+ }
118
+
119
+ /** Resolve global MCPs from the data-dir config (both shorthand vendor entries and full entries) */
120
+ export function getGlobalMcpsFromConfig(): McpConfig {
121
+ const ucConfig = getShragaConfigSync();
122
+ const entries = ucConfig.mcps ?? ucConfig.vendorMcps ?? {};
123
+ const result: McpConfig = {};
124
+
125
+ for (const [name, entry] of Object.entries(entries)) {
126
+ if (isHttpEntry(entry)) {
127
+ const { dir: _dir, port: _port, ...httpConfig } = entry;
128
+ result[name] = httpConfig satisfies McpHttpServerConfig;
129
+ } else if (isFullEntry(entry)) {
130
+ const full = entry;
131
+ result[name] = { type: 'stdio', ...full } satisfies McpServerConfig;
132
+ } else {
133
+ const shorthand = entry as McpShorthandEntry;
134
+ const vendorDir = path.join(PROJECT_ROOT, 'vendor', shorthand.dir ?? name);
135
+ const command = shorthand.command ?? 'bun';
136
+ const args = shorthand.args ?? ['run', path.join(vendorDir, 'src/mcp/cli.ts'), '--stdio'];
137
+ const env: Record<string, string> = {};
138
+ if (Array.isArray(shorthand.env)) {
139
+ for (const key of shorthand.env) env[key] = '';
140
+ } else if (shorthand.env && typeof shorthand.env === 'object') {
141
+ // object form: explicit name->value (or $PLACEHOLDER resolved by env-resolve). A malformed
142
+ // env must never crash MCP setup for ALL chat turns — degrade gracefully.
143
+ for (const [k, v] of Object.entries(shorthand.env)) env[k] = typeof v === 'string' ? v : '';
144
+ }
145
+ result[name] = { type: 'stdio', command, args, env } satisfies McpServerConfig;
146
+ }
147
+ }
148
+
149
+ return result;
150
+ }
151
+
152
+ /** Extract HTTP sidecar specs that need to be auto-started */
153
+ export function getHttpSidecarSpecs(): HttpSidecarSpec[] {
154
+ const ucConfig = getShragaConfigSync();
155
+ const entries = ucConfig.mcps ?? {};
156
+ const specs: HttpSidecarSpec[] = [];
157
+
158
+ for (const [name, entry] of Object.entries(entries)) {
159
+ if (!isHttpEntry(entry) || !entry.dir) continue;
160
+ if (entry.enabled === false) continue;
161
+ const urlPort = new URL(entry.url).port;
162
+ const port = entry.port ?? (urlPort ? parseInt(urlPort) : 3846);
163
+ specs.push({ name, dir: entry.dir, port, url: entry.url });
164
+ }
165
+
166
+ return specs;
167
+ }
@@ -0,0 +1,372 @@
1
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DATA_DIR, dataPath } from './paths.ts';
4
+ import { getBuiltinSkillNames } from './seed.ts';
5
+ import { dataSync } from './data-sync.ts';
6
+ import { injectFile } from './file-inject.ts';
7
+ import { getGlobalMcpConfig } from './mcp.ts';
8
+
9
+ const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
10
+
11
+ const SKILLS_DIR = dataPath('skills');
12
+ const DEFAULTS_PATH = dataPath('skills-defaults.json');
13
+
14
+ function ensureDir() {
15
+ mkdirSync(SKILLS_DIR, { recursive: true });
16
+ }
17
+
18
+ export interface SkillMeta {
19
+ description?: string;
20
+ model?: string;
21
+ allowedTools?: string[];
22
+ argumentHint?: string;
23
+ triggers?: string[];
24
+ expires?: string;
25
+ origin?: string;
26
+ reviewed?: boolean;
27
+ }
28
+
29
+ export function isExpired(meta: SkillMeta): boolean {
30
+ if (!meta.expires) return false;
31
+ return new Date(meta.expires).getTime() < Date.now();
32
+ }
33
+
34
+ export interface Skill {
35
+ name: string;
36
+ content: string;
37
+ builtin: boolean;
38
+ meta: SkillMeta;
39
+ }
40
+
41
+ export function parseSkillFrontmatter(content: string): { meta: SkillMeta; body: string } {
42
+ if (!content.startsWith('---')) return { meta: {}, body: content };
43
+ const end = content.indexOf('---', 3);
44
+ if (end < 0) return { meta: {}, body: content };
45
+ const yaml = content.slice(3, end).trim();
46
+ const body = content.slice(end + 3).trim();
47
+ const meta: SkillMeta = {};
48
+ const lines = yaml.split('\n');
49
+ let currentKey = '';
50
+ for (const line of lines) {
51
+ const listMatch = line.match(/^\s+-\s+"?([^"]+)"?\s*$/);
52
+ if (listMatch && currentKey === 'triggers') {
53
+ (meta.triggers ??= []).push(listMatch[1]);
54
+ continue;
55
+ }
56
+ const [k, ...rest] = line.split(':');
57
+ const key = k.trim();
58
+ const val = rest.join(':').trim();
59
+ currentKey = key;
60
+ if (key === 'description') meta.description = val;
61
+ if (key === 'model') meta.model = val;
62
+ if (key === 'allowed-tools') meta.allowedTools = val.split(',').map(s => s.trim());
63
+ if (key === 'argument-hint') meta.argumentHint = val;
64
+ if (key === 'triggers' && val) {
65
+ try { meta.triggers = JSON.parse(val); } catch {
66
+ meta.triggers = val.split(',').map(s => s.trim());
67
+ }
68
+ }
69
+ if (key === 'expires') meta.expires = val;
70
+ if (key === 'origin') meta.origin = val;
71
+ if (key === 'reviewed') meta.reviewed = val === 'true' ? true : val === 'false' ? false : undefined;
72
+ }
73
+ return { meta, body };
74
+ }
75
+
76
+ export function isBuiltin(name: string): boolean {
77
+ return getBuiltinSkillNames().includes(name);
78
+ }
79
+
80
+ export function listSkills(): string[] {
81
+ ensureDir();
82
+ return readdirSync(SKILLS_DIR)
83
+ .filter((f) => f.endsWith('.md'))
84
+ .map((f) => f.slice(0, -3));
85
+ }
86
+
87
+ export function listMcpCommands(): string[] {
88
+ const skillNames = new Set(listSkills());
89
+ return Object.keys(getGlobalMcpConfig()).filter(name => !skillNames.has(name));
90
+ }
91
+
92
+ export function getMcpCommandPrompt(mcpName: string, args: string): string {
93
+ const bundled = resolveMcpBundledSkillContent(mcpName);
94
+ if (bundled) {
95
+ const { body } = parseSkillFrontmatter(bundled);
96
+ return formatMcpCommandBlock(mcpName, body, args);
97
+ }
98
+ return formatMcpCommandBlock(mcpName, '', args);
99
+ }
100
+
101
+ function formatMcpCommandBlock(mcpName: string, skillBody: string, args: string): string {
102
+ const instruction = args || 'Show available tools and what you can do.';
103
+ const context = skillBody ? `\n\nReference:\n${skillBody}` : '';
104
+ return `<command name="${mcpName}">\nUse the ${mcpName} MCP tools to: ${instruction}${context}\n</command>`;
105
+ }
106
+
107
+ /**
108
+ * Markdown from `vendor/<serverName>/.claude/skills/<serverName>/SKILL.md` — same file the MCP exposes as skill://serverName/workflow.
109
+ */
110
+ export function resolveMcpBundledSkillContent(serverName: string): string | null {
111
+ const file = path.join(PROJECT_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
112
+ if (!existsSync(file)) return null;
113
+ return readFileSync(file, 'utf-8');
114
+ }
115
+
116
+ /**
117
+ * One `<skill>` block; `mcp-resource-uri` only when vendor SKILL.md exists (same bytes as MCP resources/read).
118
+ */
119
+ export function resolvedSkillInjectionBlock(name: string): string | null {
120
+ const bundled = resolveMcpBundledSkillContent(name);
121
+ if (bundled !== null) {
122
+ return `<skill name="${name}" mcp-resource-uri="skill://${name}/workflow">\n${bundled}\n</skill>`;
123
+ }
124
+ const s = getSkill(name);
125
+ if (!s) return null;
126
+ const { body } = parseSkillFrontmatter(s.content);
127
+ return `<skill name="${name}">\n${body}\n</skill>`;
128
+ }
129
+
130
+ function mcpSkillFilePath(serverName: string): string {
131
+ return path.join(PROJECT_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
132
+ }
133
+
134
+ /**
135
+ * Build a compact block for all active MCP skills.
136
+ * @param mcpNames — active MCP server names
137
+ * @param maxChars — 0 = hint-only (name + resource URI), >0 = injectFile with that limit
138
+ */
139
+ export function buildMcpSkillHintsBlock(mcpNames: string[], maxChars = 0): string {
140
+ const entries: string[] = [];
141
+ for (const name of mcpNames) {
142
+ const file = mcpSkillFilePath(name);
143
+ if (!existsSync(file)) continue;
144
+ if (maxChars > 0) {
145
+ const block = injectFile(file, { label: 'mcp-skill', maxChars });
146
+ if (block) entries.push(block);
147
+ } else {
148
+ let content = readFileSync(file, 'utf-8');
149
+ if (content.startsWith('---')) {
150
+ const endIdx = content.indexOf('---', 3);
151
+ if (endIdx > 0) content = content.slice(endIdx + 3);
152
+ }
153
+ const desc = content.split('\n').find(l => l.trim() && !l.startsWith('#'))?.trim() ?? name;
154
+ entries.push(`- **${name}**: \`skill://${name}/workflow\` — ${desc}`);
155
+ }
156
+ }
157
+ if (entries.length === 0) return '';
158
+ if (maxChars > 0) return `<mcp-skills>\n${entries.join('\n')}\n</mcp-skills>`;
159
+ return `<mcp-skills>\nActive MCP servers with workflow docs — read the skill resource URI for full tool reference and usage patterns:\n${entries.join('\n')}\n</mcp-skills>`;
160
+ }
161
+
162
+ export function getSkill(name: string): Skill | null {
163
+ const file = path.join(SKILLS_DIR, `${name}.md`);
164
+ if (!existsSync(file)) return null;
165
+ const raw = readFileSync(file, 'utf-8');
166
+ const { meta } = parseSkillFrontmatter(raw);
167
+ return { name, content: raw, builtin: isBuiltin(name), meta };
168
+ }
169
+
170
+ export function saveSkill(name: string, content: string): void {
171
+ if (isBuiltin(name)) throw new Error(`Cannot modify built-in skill "${name}"`);
172
+ ensureDir();
173
+ writeFileSync(path.join(SKILLS_DIR, `${name}.md`), content);
174
+ dataSync.trackWrite(`skills/${name}.md`);
175
+ }
176
+
177
+ export function deleteSkill(name: string): void {
178
+ if (isBuiltin(name)) throw new Error(`Cannot delete built-in skill "${name}"`);
179
+ const file = path.join(SKILLS_DIR, `${name}.md`);
180
+ if (existsSync(file)) unlinkSync(file);
181
+ dataSync.trackWrite(`skills/${name}.md`);
182
+ }
183
+
184
+ export function duplicateSkill(source: string, newName: string): Skill {
185
+ const original = getSkill(source);
186
+ if (!original) throw new Error(`Skill "${source}" not found`);
187
+ if (existsSync(path.join(SKILLS_DIR, `${newName}.md`))) throw new Error(`Skill "${newName}" already exists`);
188
+ ensureDir();
189
+ writeFileSync(path.join(SKILLS_DIR, `${newName}.md`), original.content);
190
+ dataSync.trackWrite(`skills/${newName}.md`);
191
+ return { name: newName, content: original.content, builtin: false, meta: original.meta };
192
+ }
193
+
194
+ export function renameSkill(oldName: string, newName: string): void {
195
+ if (isBuiltin(oldName)) throw new Error(`Cannot rename built-in skill "${oldName}"`);
196
+ const oldFile = path.join(SKILLS_DIR, `${oldName}.md`);
197
+ const newFile = path.join(SKILLS_DIR, `${newName}.md`);
198
+ if (!existsSync(oldFile)) throw new Error(`Skill "${oldName}" not found`);
199
+ if (existsSync(newFile)) throw new Error(`Skill "${newName}" already exists`);
200
+ renameSync(oldFile, newFile);
201
+ dataSync.trackWrite(`skills/${oldName}.md`);
202
+ dataSync.trackWrite(`skills/${newName}.md`);
203
+ // Update defaults list if the old name was a default
204
+ const defs = getDefaultSkills();
205
+ const idx = defs.findIndex(e => parseDefaultEntry(e).name === oldName);
206
+ if (idx !== -1) {
207
+ const prev = defs[idx];
208
+ if (typeof prev === 'string') {
209
+ defs[idx] = newName;
210
+ } else {
211
+ defs[idx] = { ...prev, name: newName };
212
+ }
213
+ setDefaultSkills(defs);
214
+ }
215
+ }
216
+
217
+ // Default skills — injected into every conversation
218
+ export function getDefaultSkills(): DefaultSkillEntry[] {
219
+ if (!existsSync(DEFAULTS_PATH)) return [];
220
+ try { return JSON.parse(readFileSync(DEFAULTS_PATH, 'utf-8')); } catch { return []; }
221
+ }
222
+
223
+ export function getDefaultSkillNames(): string[] {
224
+ return getDefaultSkills().map(e => parseDefaultEntry(e).name);
225
+ }
226
+
227
+ export function setDefaultSkills(names: DefaultSkillEntry[]): void {
228
+ mkdirSync(DATA_DIR, { recursive: true });
229
+ writeFileSync(DEFAULTS_PATH, JSON.stringify(names, null, 2));
230
+ dataSync.trackWrite('skills-defaults.json');
231
+ }
232
+
233
+ /** `string` = full inject, `{ name, capped: true }` = capped at default, `{ name, capped: 600 }` = custom cap */
234
+ export type DefaultSkillEntry = string | { name: string; capped?: boolean | number };
235
+
236
+ function parseDefaultEntry(entry: DefaultSkillEntry): { name: string; capChars: number } {
237
+ if (typeof entry === 'string') return { name: entry, capChars: 0 };
238
+ const c = entry.capped;
239
+ if (c === true) return { name: entry.name, capChars: 600 };
240
+ if (typeof c === 'number' && c > 0) return { name: entry.name, capChars: c };
241
+ return { name: entry.name, capChars: 0 };
242
+ }
243
+
244
+ export function resolveDefaultSkillsContent(): string {
245
+ const defaults = getDefaultSkills();
246
+ if (defaults.length === 0) return '';
247
+ const blocks: string[] = [];
248
+ for (const entry of defaults) {
249
+ const { name, capChars } = parseDefaultEntry(entry);
250
+ const skill = getSkill(name);
251
+ if (skill && isExpired(skill.meta)) continue;
252
+ if (capChars > 0) {
253
+ const file = path.join(SKILLS_DIR, `${name}.md`);
254
+ const block = injectFile(file, { label: 'skill', maxChars: capChars });
255
+ if (block) blocks.push(block);
256
+ } else {
257
+ const block = resolvedSkillInjectionBlock(name);
258
+ if (block) blocks.push(block);
259
+ }
260
+ }
261
+ return blocks.join('\n');
262
+ }
263
+
264
+ export function expandMentionedSkills(text: string): string {
265
+ const mentions = [...new Set(Array.from(text.matchAll(/@([\w-]+)/g), (m) => m[1]))];
266
+ if (mentions.length === 0) return text;
267
+ const blocks = mentions
268
+ .map((name) => {
269
+ const file = path.join(SKILLS_DIR, `${name}.md`);
270
+ if (existsSync(file)) return injectFile(file, { label: 'skill', maxChars: 600 });
271
+ const vendorFile = mcpSkillFilePath(name);
272
+ if (existsSync(vendorFile)) return injectFile(vendorFile, { label: 'skill', maxChars: 600 });
273
+ return null;
274
+ })
275
+ .filter((s): s is string => !!s);
276
+ if (blocks.length === 0) return text;
277
+ return `${blocks.join('\n')}\n\n${text}`;
278
+ }
279
+
280
+ /**
281
+ * Compact index of ALL available skills — name, description, triggers.
282
+ * Injected into every session so the agent knows what's available.
283
+ */
284
+ export function buildSkillIndexBlock(): string {
285
+ const names = listSkills();
286
+ if (names.length === 0) return '';
287
+ const defaultNames = new Set(getDefaultSkillNames());
288
+ const lines: string[] = [];
289
+ for (const name of names) {
290
+ const skill = getSkill(name);
291
+ if (!skill) continue;
292
+ const { meta } = parseSkillFrontmatter(skill.content);
293
+ if (isExpired(meta)) continue;
294
+ const desc = meta.description || '';
295
+ const triggers = meta.triggers?.length ? ` [triggers: ${meta.triggers.join(', ')}]` : '';
296
+ const expiry = meta.expires ? ` [expires: ${meta.expires}]` : '';
297
+ const tag = defaultNames.has(name) ? ' (default)' : '';
298
+ lines.push(`- **${name}**${tag}: ${desc}${triggers}${expiry}`);
299
+ }
300
+ return `<available-skills>\nSkills available in data/skills/. Use Read to load full skill content when needed.\n${lines.join('\n')}\n</available-skills>`;
301
+ }
302
+
303
+ /**
304
+ * Match message text against skill triggers. Returns matched skill names.
305
+ * Skips skills already in the defaults list (they're already injected).
306
+ */
307
+ export function matchTriggeredSkillNames(message: string, context?: Record<string, string>): string[] {
308
+ const ctxPrefix = context
309
+ ? Object.entries(context).map(([k, v]) => `[${k}:${v}]`).join(' ')
310
+ : '';
311
+ const triggerInput = ctxPrefix ? `${ctxPrefix} ${message}` : message;
312
+ const lower = triggerInput.toLowerCase();
313
+ const defaultNames = new Set(getDefaultSkillNames());
314
+ const matched: string[] = [];
315
+ for (const name of listSkills()) {
316
+ if (defaultNames.has(name)) continue;
317
+ const skill = getSkill(name);
318
+ if (!skill) continue;
319
+ const { meta } = parseSkillFrontmatter(skill.content);
320
+ if (isExpired(meta)) continue;
321
+ if (!meta.triggers?.length) continue;
322
+ if (meta.origin === 'auto' && meta.reviewed === false) continue;
323
+ const hit = meta.triggers.some(t => lower.includes(t.toLowerCase()));
324
+ if (hit) {
325
+ console.log(`[skills] Trigger matched: ${name}${ctxPrefix ? ` (context: ${ctxPrefix})` : ''}`);
326
+ matched.push(name);
327
+ }
328
+ }
329
+ return matched;
330
+ }
331
+
332
+ /** Build injection blocks for a list of skill names (deleted/unresolvable skills and defaults — already injected — are skipped). */
333
+ export function skillInjectionBlocks(names: string[]): string {
334
+ const defaultNames = new Set(getDefaultSkillNames());
335
+ return names
336
+ .filter((name) => !defaultNames.has(name))
337
+ .map((name) => resolvedSkillInjectionBlock(name))
338
+ .filter((b): b is string => !!b)
339
+ .join('\n');
340
+ }
341
+
342
+ /** Lint skills for index visibility — returns warnings for skills missing description frontmatter. */
343
+ export function lintSkills(): string[] {
344
+ const warnings: string[] = [];
345
+ for (const name of listSkills()) {
346
+ const skill = getSkill(name);
347
+ if (!skill) continue;
348
+ const { meta } = parseSkillFrontmatter(skill.content);
349
+ if (!meta.description) warnings.push(`${name}: missing description frontmatter — invisible in the skill index`);
350
+ }
351
+ return warnings;
352
+ }
353
+
354
+ export function purgeExpiredSkills(): string[] {
355
+ const purged: string[] = [];
356
+ for (const name of listSkills()) {
357
+ if (isBuiltin(name)) continue;
358
+ const skill = getSkill(name);
359
+ if (!skill) continue;
360
+ if (isExpired(skill.meta)) {
361
+ console.log(`[skills] Purging expired skill: ${name} (expired ${skill.meta.expires})`);
362
+ unlinkSync(path.join(SKILLS_DIR, `${name}.md`));
363
+ purged.push(name);
364
+ }
365
+ }
366
+ if (purged.length) {
367
+ const defaults = getDefaultSkills();
368
+ const filtered = defaults.filter(e => !purged.includes(parseDefaultEntry(e).name));
369
+ if (filtered.length < defaults.length) setDefaultSkills(filtered);
370
+ }
371
+ return purged;
372
+ }
@@ -0,0 +1,37 @@
1
+ // Slack client — the ONE agent-glue seam onto the mcp-slack-use package `client` (unified Slack
2
+ // Web API, single bot-vs-user token-resolution path). The former duplicate `slackPost` is gone:
3
+ // everything routes through the package. Only contact/mention resolution stays here — this app owns
4
+ // the contacts store, so it can't live in the vendor package.
5
+ import * as contacts from '../contacts.ts';
6
+ import { getUserProfile } from 'mcp-slack-use/src/client.ts';
7
+
8
+ export * from 'mcp-slack-use/src/client.ts';
9
+
10
+ /** Rewrite Slack `<@Uxxx>` (and bare user ids) to `@Name (operator)?`, learning contacts as it goes. */
11
+ export async function resolveUserMentions(text: string): Promise<string> {
12
+ const { upsert, find } = contacts;
13
+ const mentions = [...text.matchAll(/<@(U[A-Z0-9]+)>|\b(U[A-Z0-9]{8,12})\b/g)];
14
+ if (!mentions.length) return text;
15
+ const ids = mentions.map(m => m[1] || m[2]);
16
+ const profiles = await Promise.all(ids.map(id => getUserProfile(id).catch(() => ({ name: null, email: null, title: null }))));
17
+ let result = text;
18
+ for (let i = 0; i < mentions.length; i++) {
19
+ const { name, email, title } = profiles[i];
20
+ const id = ids[i];
21
+ if (name) {
22
+ const contact = upsert({ slackId: id, email: email || undefined, name, role: title || undefined });
23
+ const tag = contact.isOperator ? ' (operator)' : '';
24
+ result = result.replace(mentions[i][0], `@${name}${tag}`);
25
+ } else {
26
+ const existing = find({ slackId: id });
27
+ if (existing) {
28
+ const tag = existing.isOperator ? ' (operator)' : '';
29
+ result = result.replace(mentions[i][0], `@${existing.name}${tag}`);
30
+ } else {
31
+ result = result.replace(mentions[i][0], `@unknown (Slack ID: ${id})`);
32
+ console.warn(`[slack] Could not resolve user ${id} — may be external/Slack Connect user`);
33
+ }
34
+ }
35
+ }
36
+ return result;
37
+ }