@heuresis/mcp 1.0.0-rc.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.
@@ -0,0 +1,8 @@
1
+ // Postgres row types — narrow subset duplicated from src/cloud/types.ts so the
2
+ // MCP package builds independently of the main app. Only the rows we actually
3
+ // touch from MCP tools (nodes, edges, projects, project_nodes, workspaces,
4
+ // workspace_memberships, ideas, idea_nodes) are mirrored here. Add more as the
5
+ // 19.4 tool-parity wave expands.
6
+ //
7
+ // Field names are snake_case to match what supabase-js returns.
8
+ export {};
@@ -0,0 +1,97 @@
1
+ // Credentials persistence at ~/.heuresis/credentials.json (chmod 600 on POSIX).
2
+ //
3
+ // The shape is intentionally small — supabase-js handles the access-token
4
+ // lifecycle from the refresh token, so all we ever need to write to disk is
5
+ // the refresh token and the project URL/anon key it pairs with.
6
+ //
7
+ // Format:
8
+ // {
9
+ // "supabase_url": "https://xyz.supabase.co",
10
+ // "anon_key": "ey...",
11
+ // "refresh_token":"ey...",
12
+ // "user_id": "uuid",
13
+ // "device_name": "hostname-shortRandom",
14
+ // "created_at": "2026-05-21T..."
15
+ // }
16
+ //
17
+ // Phase 19.3 will move refresh-token issuance behind the `mcp-device-grant`
18
+ // Edge Function and add a `refresh_token_id` column we record server-side.
19
+ // For now (19.1) the shape on disk matches what the user will paste from the
20
+ // browser link.
21
+ import { mkdir, readFile, writeFile, chmod, unlink, stat } from 'node:fs/promises';
22
+ import { existsSync } from 'node:fs';
23
+ import { homedir, hostname } from 'node:os';
24
+ import { dirname, join } from 'node:path';
25
+ import { randomBytes } from 'node:crypto';
26
+ const isWindows = process.platform === 'win32';
27
+ export function credentialsPath() {
28
+ return join(homedir(), '.heuresis', 'credentials.json');
29
+ }
30
+ export async function readCredentials() {
31
+ const path = credentialsPath();
32
+ if (!existsSync(path))
33
+ return null;
34
+ try {
35
+ const text = await readFile(path, 'utf8');
36
+ const data = JSON.parse(text);
37
+ if (!data.supabase_url ||
38
+ !data.anon_key ||
39
+ !data.refresh_token ||
40
+ !data.user_id ||
41
+ !data.device_name ||
42
+ !data.created_at) {
43
+ return null;
44
+ }
45
+ return data;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export async function writeCredentials(creds) {
52
+ const path = credentialsPath();
53
+ await mkdir(dirname(path), { recursive: true });
54
+ await writeFile(path, JSON.stringify(creds, null, 2), 'utf8');
55
+ // chmod 600 — owner read/write only. No-op on Windows (NTFS perms are a
56
+ // different model and the file lives in %USERPROFILE% which is already
57
+ // user-private by default).
58
+ if (!isWindows) {
59
+ try {
60
+ await chmod(path, 0o600);
61
+ }
62
+ catch {
63
+ // best-effort; don't fail login just because of perm bits.
64
+ }
65
+ }
66
+ return path;
67
+ }
68
+ export async function deleteCredentials() {
69
+ const path = credentialsPath();
70
+ if (!existsSync(path))
71
+ return false;
72
+ try {
73
+ await unlink(path);
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ export async function credentialsExist() {
81
+ const path = credentialsPath();
82
+ if (!existsSync(path))
83
+ return false;
84
+ try {
85
+ const s = await stat(path);
86
+ return s.isFile();
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ /** Default device name = `${hostname}-${shortRandom}`. */
93
+ export function defaultDeviceName() {
94
+ const host = hostname().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32) || 'device';
95
+ const rand = randomBytes(3).toString('hex');
96
+ return `${host}-${rand}`;
97
+ }
package/dist/index.js ADDED
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ // @heuresis/mcp — Heuresis Model Context Protocol server (v0.2.0-alpha).
3
+ //
4
+ // Two operating modes:
5
+ //
6
+ // 1. CLOUD (default, Phase 19.1+) — when ~/.heuresis/credentials.json
7
+ // exists, every tool call hits Supabase against the user's session.
8
+ // Same workspace the webapp sees, same RLS, live reads + writes.
9
+ //
10
+ // 2. LEGACY SNAPSHOT (deprecated, removed after 19.7) — when no
11
+ // credentials are present AND $HEURESIS_SNAPSHOT is set, fall back
12
+ // to the v0 read-only file-snapshot behavior. This keeps existing
13
+ // installs working through the migration.
14
+ //
15
+ // Subcommands (one-shot, never start the MCP server):
16
+ // login | logout | whoami | --help
17
+ import { existsSync } from 'node:fs';
18
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
21
+ import { zodToJsonSchema } from './zod-to-json-schema.js';
22
+ import { HeuresisStore } from './store.js';
23
+ import { getConcept as legacyGetConcept, getConceptInput as legacyGetConceptInput, getProjectGraph as legacyGetProjectGraph, getProjectGraphInput as legacyGetProjectGraphInput, getSubtree as legacyGetSubtree, getSubtreeInput as legacyGetSubtreeInput, getWorkspaceSummary as legacyGetWorkspaceSummary, getWorkspaceSummaryInput as legacyGetWorkspaceSummaryInput, listProjects as legacyListProjects, listProjectsInput as legacyListProjectsInput, listRecentDecisions as legacyListRecentDecisions, listRecentDecisionsInput as legacyListRecentDecisionsInput, searchConcepts as legacySearchConcepts, searchConceptsInput as legacySearchConceptsInput, } from './tools.js';
24
+ import { CLOUD_TOOLS } from './cloudTools.js';
25
+ import { readCredentials } from './credentials.js';
26
+ import { CloudAuthError, getCloudClient } from './cloudClient.js';
27
+ import { helpCommand, loginCommand, logoutCommand, whoamiCommand, } from './cli.js';
28
+ import { readRealtimeFlag, resolveSubscriptionWorkspaceId, startRealtimeSubscription, stripRealtimeFlags, } from './realtime.js';
29
+ const VERSION = '0.2.0-alpha';
30
+ const MAX_RESULT_CHARS = 50_000;
31
+ function makeCloudTools(getClient, operatorTools) {
32
+ // Lazy: defer the actual auth handshake until the first tool call so the
33
+ // MCP server boots fast.
34
+ //
35
+ // We compose two sources: CLOUD_TOOLS (Phase 19.4 data-layer parity) and
36
+ // operatorTools (Phase 19.5 LLM-backed operators). Both share the
37
+ // `CloudToolDef` shape; the only thing this layer adds is the lazy
38
+ // `getClient()` hop so the handlers in cloudTools.ts can stay client-
39
+ // agnostic.
40
+ const merged = [...CLOUD_TOOLS, ...operatorTools];
41
+ return merged.map((t) => ({
42
+ name: t.name,
43
+ description: t.description,
44
+ inputSchema: t.inputSchema,
45
+ handler: async (args) => t.handler(await getClient(), args),
46
+ }));
47
+ }
48
+ function makeLegacySnapshotTools(store) {
49
+ // LEGACY FALLBACK — removed after 19.7. Read-only, no auth, snapshot file.
50
+ return [
51
+ {
52
+ name: 'get_workspace_summary',
53
+ description: "(Legacy snapshot mode) Counts of nodes/edges/projects/ideas + a one-line overview of each project and idea. Always start here when you don't know what's in the workspace.",
54
+ inputSchema: legacyGetWorkspaceSummaryInput,
55
+ handler: () => legacyGetWorkspaceSummary(store),
56
+ },
57
+ {
58
+ name: 'list_projects',
59
+ description: '(Legacy snapshot mode) Every project in the snapshot with brief, direction, lifecycle, and member count.',
60
+ inputSchema: legacyListProjectsInput,
61
+ handler: () => legacyListProjects(store),
62
+ },
63
+ {
64
+ name: 'search_concepts',
65
+ description: '(Legacy snapshot mode) Substring search across concept labels, descriptions, tags, and partition attributes.',
66
+ inputSchema: legacySearchConceptsInput,
67
+ handler: (args) => legacySearchConcepts(store, legacySearchConceptsInput.parse(args)),
68
+ },
69
+ {
70
+ name: 'get_concept',
71
+ description: '(Legacy snapshot mode) One concept by id, optionally with ancestry, children, and idea memberships.',
72
+ inputSchema: legacyGetConceptInput,
73
+ handler: (args) => legacyGetConcept(store, legacyGetConceptInput.parse(args)),
74
+ },
75
+ {
76
+ name: 'get_subtree',
77
+ description: '(Legacy snapshot mode) A node and its descendants up to a given depth.',
78
+ inputSchema: legacyGetSubtreeInput,
79
+ handler: (args) => legacyGetSubtree(store, legacyGetSubtreeInput.parse(args)),
80
+ },
81
+ {
82
+ name: 'get_project_graph',
83
+ description: '(Legacy snapshot mode) Every node + edge inside one project. Returns a graph the agent can reason over end-to-end.',
84
+ inputSchema: legacyGetProjectGraphInput,
85
+ handler: (args) => legacyGetProjectGraph(store, legacyGetProjectGraphInput.parse(args)),
86
+ },
87
+ {
88
+ name: 'list_recent_decisions',
89
+ description: '(Legacy snapshot mode) Nodes the user has explicitly resolved (validated, starred, or archived) recently.',
90
+ inputSchema: legacyListRecentDecisionsInput,
91
+ handler: (args) => legacyListRecentDecisions(store, legacyListRecentDecisionsInput.parse(args)),
92
+ },
93
+ ];
94
+ }
95
+ async function runServer() {
96
+ const creds = await readCredentials();
97
+ const snapshotEnv = process.env.HEURESIS_SNAPSHOT;
98
+ let tools;
99
+ let modeBanner;
100
+ if (creds) {
101
+ // CLOUD mode.
102
+ const getClient = async () => {
103
+ try {
104
+ const { client } = await getCloudClient(creds);
105
+ return client;
106
+ }
107
+ catch (err) {
108
+ if (err instanceof CloudAuthError) {
109
+ throw new Error(err.message);
110
+ }
111
+ throw err;
112
+ }
113
+ };
114
+ // Phase 19.5 — try to load Operator tools. The module may not exist
115
+ // yet at build time (Agent A ships it in a parallel pass); if it's
116
+ // missing OR exports nothing, we fall back to just the Phase 19.4
117
+ // parity set. Wrapping the dynamic import in try/catch keeps the
118
+ // server starting cleanly in either case.
119
+ let operatorTools = [];
120
+ try {
121
+ const mod = (await import('./cloudOperators.js').catch(() => null));
122
+ if (mod && Array.isArray(mod.OPERATOR_TOOLS)) {
123
+ operatorTools = mod.OPERATOR_TOOLS;
124
+ }
125
+ }
126
+ catch {
127
+ operatorTools = [];
128
+ }
129
+ tools = makeCloudTools(getClient, operatorTools);
130
+ modeBanner = `cloud-authenticated (user_id ${creds.user_id}, device ${creds.device_name}; ${tools.length} tools)`;
131
+ }
132
+ else if (snapshotEnv || hasDefaultSnapshot()) {
133
+ // LEGACY snapshot fallback.
134
+ const store = new HeuresisStore();
135
+ tools = makeLegacySnapshotTools(store);
136
+ modeBanner = `legacy snapshot mode (path: ${store.getSnapshotPath()})`;
137
+ }
138
+ else {
139
+ // Neither — print actionable error and exit.
140
+ console.error([
141
+ '[heuresis-mcp] Not configured.',
142
+ '',
143
+ 'To use cloud mode (recommended):',
144
+ ' npx @heuresis/mcp login',
145
+ '',
146
+ 'To use legacy snapshot mode (deprecated, removed after 19.7):',
147
+ ' HEURESIS_SNAPSHOT=/path/to/export.json npx @heuresis/mcp',
148
+ '',
149
+ 'See https://heuresis.app/mcp for setup details.',
150
+ ].join('\n'));
151
+ process.exit(1);
152
+ }
153
+ // `resources` + `logging` are declared so the Realtime path (Phase 19.8) can
154
+ // call `server.sendResourceListChanged()` / `sendLoggingMessage()` without
155
+ // tripping the SDK's capability assertions. We don't expose any actual
156
+ // resource handlers, but the notification surface is what the realtime
157
+ // subscriber needs to ping the client when the workspace changes.
158
+ const server = new Server({ name: '@heuresis/mcp', version: VERSION }, { capabilities: { tools: {}, resources: { listChanged: true }, logging: {} } });
159
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
160
+ tools: tools.map((t) => ({
161
+ name: t.name,
162
+ description: t.description,
163
+ inputSchema: zodToJsonSchema(t.inputSchema),
164
+ })),
165
+ }));
166
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
167
+ const tool = tools.find((t) => t.name === req.params.name);
168
+ if (!tool) {
169
+ return {
170
+ isError: true,
171
+ content: [
172
+ { type: 'text', text: `Unknown tool: ${req.params.name}` },
173
+ ],
174
+ };
175
+ }
176
+ try {
177
+ const result = await tool.handler(req.params.arguments ?? {});
178
+ const text = JSON.stringify(result, null, 2);
179
+ if (text.length > MAX_RESULT_CHARS) {
180
+ return {
181
+ isError: true,
182
+ content: [
183
+ {
184
+ type: 'text',
185
+ text: `Result too large (${text.length} chars, limit ${MAX_RESULT_CHARS}). ` +
186
+ `Narrow the query: lower 'limit'/'depth', keep detail='compact', ` +
187
+ `or fetch individual nodes with get_concept.`,
188
+ },
189
+ ],
190
+ };
191
+ }
192
+ return {
193
+ content: [{ type: 'text', text }],
194
+ };
195
+ }
196
+ catch (err) {
197
+ const msg = err instanceof Error ? err.message : String(err);
198
+ return {
199
+ isError: true,
200
+ content: [{ type: 'text', text: `Error: ${msg}` }],
201
+ };
202
+ }
203
+ });
204
+ const transport = new StdioServerTransport();
205
+ await server.connect(transport);
206
+ console.error(`[heuresis-mcp ${VERSION}] ready - ${modeBanner}`);
207
+ // Phase 19.8 - Supabase Realtime CDC subscription. Cloud mode only; legacy
208
+ // snapshot mode has no live source to subscribe to.
209
+ if (creds) {
210
+ const realtimeOn = await readRealtimeFlag();
211
+ if (!realtimeOn) {
212
+ console.error('[heuresis-mcp] realtime: disabled (--no-realtime or config).');
213
+ }
214
+ else {
215
+ // Don't block boot on the realtime handshake; fire-and-forget. If the
216
+ // client (Supabase) is not reachable, the error surfaces on stderr.
217
+ void (async () => {
218
+ try {
219
+ const { client } = await getCloudClient(creds);
220
+ const wsId = await resolveSubscriptionWorkspaceId(client);
221
+ if (!wsId) {
222
+ console.error('[heuresis-mcp] realtime: no workspace visible; skipping subscription.');
223
+ return;
224
+ }
225
+ startRealtimeSubscription(client, wsId, (event) => {
226
+ if (event.eventType === 'RESYNC') {
227
+ console.error('[heuresis-mcp] workspace resync: refetch any cached state.');
228
+ }
229
+ else if (event.table) {
230
+ console.error(`[heuresis-mcp] workspace updated: ${event.table} ${event.eventType}`);
231
+ }
232
+ // Best-effort MCP notification. Most clients will treat this as a
233
+ // hint to refresh. We swallow errors because not every client
234
+ // honors the resources capability.
235
+ void server.sendResourceListChanged().catch(() => {
236
+ /* client does not implement resource updates; stderr log is enough */
237
+ });
238
+ });
239
+ }
240
+ catch (err) {
241
+ const msg = err instanceof Error ? err.message : String(err);
242
+ console.error(`[heuresis-mcp] realtime: subscription failed: ${msg}`);
243
+ }
244
+ })();
245
+ }
246
+ }
247
+ }
248
+ function hasDefaultSnapshot() {
249
+ try {
250
+ const s = new HeuresisStore();
251
+ // Only count the default path if it actually exists on disk; we never
252
+ // want the default-path branch to trigger a "snapshot not found" error
253
+ // when the user simply hasn't logged in yet.
254
+ return existsSync(s.getSnapshotPath());
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
260
+ async function main() {
261
+ // The Realtime flags (`--no-realtime` / `--realtime`) are consumed by the
262
+ // realtime module via process.argv directly; strip them here so they don't
263
+ // collide with subcommand dispatch when a user runs e.g.
264
+ // `npx @heuresis/mcp --no-realtime`.
265
+ const stripped = stripRealtimeFlags(process.argv.slice(2));
266
+ const sub = stripped[0];
267
+ switch (sub) {
268
+ case undefined:
269
+ await runServer();
270
+ return;
271
+ case 'login':
272
+ await loginCommand(stripped.slice(1));
273
+ return;
274
+ case 'logout':
275
+ await logoutCommand();
276
+ return;
277
+ case 'whoami':
278
+ await whoamiCommand();
279
+ return;
280
+ case '-h':
281
+ case '--help':
282
+ case 'help':
283
+ helpCommand();
284
+ return;
285
+ default:
286
+ console.error(`Unknown subcommand: ${sub}`);
287
+ helpCommand();
288
+ process.exit(2);
289
+ }
290
+ }
291
+ main().catch((err) => {
292
+ console.error('[heuresis-mcp] fatal:', err);
293
+ process.exit(1);
294
+ });
@@ -0,0 +1,155 @@
1
+ // LLM client — server-side analogue of `src/llm/client.ts`. Same four
2
+ // providers (Anthropic, OpenAI, OpenRouter, Google), same defaults, but
3
+ // streaming is collapsed into a single response since MCP tool calls return
4
+ // a single payload anyway. No `dangerouslyAllowBrowser` because we're in
5
+ // Node — the SDKs work natively here.
6
+ //
7
+ // Key resolution: the caller passes the key already resolved (via the
8
+ // `get_my_provider_key` RPC in cloudOperators.ts) — this file never reads
9
+ // from disk or the cloud directly. That keeps the trust boundary at one
10
+ // place (the RPC + the resolver in cloudOperators.ts).
11
+ import Anthropic from '@anthropic-ai/sdk';
12
+ import OpenAI from 'openai';
13
+ import { GoogleGenerativeAI } from '@google/generative-ai';
14
+ const ANTHROPIC_DEFAULT = 'claude-sonnet-4-5-20250929';
15
+ const OPENAI_DEFAULT = 'gpt-4o-mini';
16
+ const OPENROUTER_DEFAULT = 'anthropic/claude-3.5-sonnet';
17
+ const GOOGLE_DEFAULT = 'gemini-2.5-flash';
18
+ export function defaultModelFor(provider) {
19
+ switch (provider) {
20
+ case 'anthropic':
21
+ return ANTHROPIC_DEFAULT;
22
+ case 'openai':
23
+ return OPENAI_DEFAULT;
24
+ case 'openrouter':
25
+ return OPENROUTER_DEFAULT;
26
+ case 'google':
27
+ return GOOGLE_DEFAULT;
28
+ }
29
+ }
30
+ export async function runLlm(config, input) {
31
+ if (!config.apiKey) {
32
+ throw new Error(`No API key provided for ${config.provider}. Resolve a key via get_my_provider_key first.`);
33
+ }
34
+ const maxTokens = input.maxTokens ?? 4096;
35
+ const temperature = input.temperature ?? 0.7;
36
+ switch (config.provider) {
37
+ case 'anthropic':
38
+ return callAnthropic(config, input, maxTokens, temperature);
39
+ case 'openai':
40
+ return callOpenAI(config, input, maxTokens, temperature);
41
+ case 'openrouter':
42
+ return callOpenRouter(config, input, maxTokens, temperature);
43
+ case 'google':
44
+ return callGoogle(config, input, maxTokens, temperature);
45
+ }
46
+ }
47
+ async function callAnthropic(config, input, maxTokens, temperature) {
48
+ const client = new Anthropic({ apiKey: config.apiKey });
49
+ // Mirror the webapp's prompt-caching strategy: when a stable systemPrefix
50
+ // is supplied, mark it as ephemeral-cacheable so repeated operator runs
51
+ // against the same doctrine block re-use the prompt cache.
52
+ const system = input.systemPrefix
53
+ ? [
54
+ {
55
+ type: 'text',
56
+ text: input.systemPrefix,
57
+ cache_control: { type: 'ephemeral' },
58
+ },
59
+ ]
60
+ : undefined;
61
+ const res = await client.messages.create({
62
+ model: config.model || ANTHROPIC_DEFAULT,
63
+ max_tokens: maxTokens,
64
+ temperature,
65
+ system,
66
+ messages: [{ role: 'user', content: input.prompt }],
67
+ });
68
+ const text = res.content
69
+ .map((b) => (b.type === 'text' ? b.text : ''))
70
+ .join('')
71
+ .trim();
72
+ return {
73
+ text,
74
+ stopReason: res.stop_reason ?? undefined,
75
+ usage: {
76
+ inputTokens: res.usage?.input_tokens,
77
+ outputTokens: res.usage?.output_tokens,
78
+ },
79
+ };
80
+ }
81
+ async function callOpenAI(config, input, maxTokens, temperature) {
82
+ const client = new OpenAI({ apiKey: config.apiKey });
83
+ const messages = [];
84
+ if (input.systemPrefix)
85
+ messages.push({ role: 'system', content: input.systemPrefix });
86
+ messages.push({ role: 'user', content: input.prompt });
87
+ const res = await client.chat.completions.create({
88
+ model: config.model || OPENAI_DEFAULT,
89
+ max_tokens: maxTokens,
90
+ temperature,
91
+ messages,
92
+ response_format: { type: 'json_object' },
93
+ });
94
+ const text = (res.choices[0]?.message?.content ?? '').trim();
95
+ return {
96
+ text,
97
+ stopReason: res.choices[0]?.finish_reason ?? undefined,
98
+ usage: {
99
+ inputTokens: res.usage?.prompt_tokens,
100
+ outputTokens: res.usage?.completion_tokens,
101
+ },
102
+ };
103
+ }
104
+ async function callOpenRouter(config, input, maxTokens, temperature) {
105
+ const client = new OpenAI({
106
+ apiKey: config.apiKey,
107
+ baseURL: 'https://openrouter.ai/api/v1',
108
+ defaultHeaders: {
109
+ 'HTTP-Referer': 'https://heuresis.app',
110
+ 'X-Title': 'Heuresis MCP',
111
+ },
112
+ });
113
+ const messages = [];
114
+ if (input.systemPrefix)
115
+ messages.push({ role: 'system', content: input.systemPrefix });
116
+ messages.push({ role: 'user', content: input.prompt });
117
+ const res = await client.chat.completions.create({
118
+ model: config.model || OPENROUTER_DEFAULT,
119
+ max_tokens: maxTokens,
120
+ temperature,
121
+ messages,
122
+ });
123
+ const text = (res.choices[0]?.message?.content ?? '').trim();
124
+ return {
125
+ text,
126
+ stopReason: res.choices[0]?.finish_reason ?? undefined,
127
+ usage: {
128
+ inputTokens: res.usage?.prompt_tokens,
129
+ outputTokens: res.usage?.completion_tokens,
130
+ },
131
+ };
132
+ }
133
+ async function callGoogle(config, input, maxTokens, temperature) {
134
+ const genAI = new GoogleGenerativeAI(config.apiKey);
135
+ const model = genAI.getGenerativeModel({
136
+ model: config.model || GOOGLE_DEFAULT,
137
+ systemInstruction: input.systemPrefix,
138
+ generationConfig: {
139
+ temperature,
140
+ maxOutputTokens: maxTokens,
141
+ responseMimeType: 'application/json',
142
+ },
143
+ });
144
+ const res = await model.generateContent(input.prompt);
145
+ const text = res.response.text().trim();
146
+ const candidate = res.response.candidates?.[0];
147
+ const meta = res.response.usageMetadata;
148
+ return {
149
+ text,
150
+ stopReason: candidate?.finishReason ?? undefined,
151
+ usage: meta
152
+ ? { inputTokens: meta.promptTokenCount, outputTokens: meta.candidatesTokenCount }
153
+ : undefined,
154
+ };
155
+ }
@@ -0,0 +1,65 @@
1
+ // Cost-preview helper — derives an estimated credit cost for an operator run
2
+ // from the rate card in `docs/credits.md` §2. Informational only; no actual
3
+ // billing happens because operator runs use BYO-key (the cost goes against
4
+ // the user's own provider account).
5
+ //
6
+ // One credit = $0.01 USD at retail. The rate card splits models into three
7
+ // classes and applies a flat 1.5× markup. We pick the class by string match
8
+ // on the model id — slightly fragile, but the alternative is to keep the
9
+ // MCP in lockstep with a server-side card. Both numbers tend back to "1
10
+ // credit for cheap, 3-4 for mid, 13-14 for top" so rough is fine.
11
+ //
12
+ // Token estimate for the prompt is `ceil(chars / 4)` (English heuristic).
13
+ // Output is assumed to mirror the operator-JSON envelope: ~1500 tokens for
14
+ // the canonical 3-5-partition response.
15
+ const RATES = {
16
+ haiku: { inputCentsPer1K: 0.025, outputCentsPer1K: 0.125 },
17
+ sonnet: { inputCentsPer1K: 0.45, outputCentsPer1K: 2.25 },
18
+ opus: { inputCentsPer1K: 2.25, outputCentsPer1K: 11.25 },
19
+ // Unknown model: assume sonnet-class so the preview doesn't undersell.
20
+ unknown: { inputCentsPer1K: 0.45, outputCentsPer1K: 2.25 },
21
+ };
22
+ const MARKUP = 1.5;
23
+ function classifyModel(provider, model) {
24
+ const m = (model || '').toLowerCase();
25
+ if (provider === 'anthropic' || provider === 'openrouter') {
26
+ if (m.includes('opus'))
27
+ return 'opus';
28
+ if (m.includes('sonnet'))
29
+ return 'sonnet';
30
+ if (m.includes('haiku'))
31
+ return 'haiku';
32
+ }
33
+ if (provider === 'openai') {
34
+ if (m.includes('mini') || m.includes('nano'))
35
+ return 'haiku';
36
+ if (m.includes('gpt-4o') || m.includes('gpt-4.1'))
37
+ return 'sonnet';
38
+ if (m.includes('o1') || m.includes('o3'))
39
+ return 'opus';
40
+ }
41
+ if (provider === 'google') {
42
+ if (m.includes('flash'))
43
+ return 'haiku';
44
+ if (m.includes('pro'))
45
+ return 'sonnet';
46
+ }
47
+ return 'unknown';
48
+ }
49
+ export function estimateCost(args) {
50
+ const cls = classifyModel(args.provider, args.model);
51
+ const rate = RATES[cls];
52
+ const inputTokens = Math.ceil(args.promptChars / 4);
53
+ const outputTokens = args.expectedOutputTokens ?? 1500;
54
+ const cents = ((inputTokens / 1000) * rate.inputCentsPer1K +
55
+ (outputTokens / 1000) * rate.outputCentsPer1K) *
56
+ MARKUP;
57
+ const credits = Math.max(1, Math.ceil(cents));
58
+ return {
59
+ credits,
60
+ dollars: Math.round(credits) / 100,
61
+ modelClass: cls,
62
+ inputTokensEst: inputTokens,
63
+ outputTokensEst: outputTokens,
64
+ };
65
+ }
@@ -0,0 +1,50 @@
1
+ // ASIT family — verbatim duplicate of src/operators/asit.ts so the MCP
2
+ // package builds independently of the main app. Keep in sync by hand if the
3
+ // webapp tweaks any doctrine or promptFragment text.
4
+ export const ASIT_OPERATORS = [
5
+ {
6
+ family: 'ASIT',
7
+ key: 'unification',
8
+ name: 'Unification',
9
+ glyph: '⊕',
10
+ oneLiner: 'One element, two roles.',
11
+ doctrine: 'Unification (Task Unification): keep the components of the system and the closed world fixed; let one of those components also perform an additional, unrelated task. Example: a phone screen also serves as a mirror.',
12
+ promptFragment: 'Apply the ASIT operator UNIFICATION (Task Unification): keep the existing components of the system fixed, and propose 3–5 partitions in which an EXISTING component is given an additional, previously-unrelated task. Do not introduce foreign components. Each partition should specify which component takes the new task and what that task is.',
13
+ },
14
+ {
15
+ family: 'ASIT',
16
+ key: 'multiplication',
17
+ name: 'Multiplication',
18
+ glyph: '×',
19
+ oneLiner: 'Add a copy with a small variation.',
20
+ doctrine: 'Multiplication: take an existing component and add a copy of it that differs in at least one property (size, position, timing, sensitivity). Closed-world: do not import alien parts.',
21
+ promptFragment: 'Apply the ASIT operator MULTIPLICATION: propose 3–5 partitions, each adding a near-copy of an EXISTING component but with at least one altered property (size, count, timing, location, sensitivity). State which component is duplicated and which property is altered.',
22
+ },
23
+ {
24
+ family: 'ASIT',
25
+ key: 'division',
26
+ name: 'Division',
27
+ glyph: '÷',
28
+ oneLiner: 'Split one element into independent parts.',
29
+ doctrine: 'Division: divide an existing component into parts and rearrange them. Splits can be physical (space), temporal (time), or functional (by attribute/condition).',
30
+ promptFragment: 'Apply the ASIT operator DIVISION: propose 3–5 partitions in which an EXISTING component is divided along space, time, or condition, and the parts are reorganised. State which component is divided and along which axis.',
31
+ },
32
+ {
33
+ family: 'ASIT',
34
+ key: 'object_removal',
35
+ name: 'Object Removal',
36
+ glyph: '⊖',
37
+ oneLiner: 'Eliminate, then redistribute the role.',
38
+ doctrine: 'Object Removal (Subtraction): remove a component that seems essential. The remaining system must achieve the goal — often by reassigning that role to another component (forces unification).',
39
+ promptFragment: 'Apply the ASIT operator OBJECT REMOVAL: propose 3–5 partitions, each removing one component that currently seems essential. For each, state which component is removed and how the remaining system still achieves the goal.',
40
+ },
41
+ {
42
+ family: 'ASIT',
43
+ key: 'breaking_symmetry',
44
+ name: 'Breaking Symmetry',
45
+ glyph: '⤧',
46
+ oneLiner: 'Replace a uniform property with a gradient.',
47
+ doctrine: 'Breaking Symmetry: take a property that is currently uniform across the system (in space, time, users, or conditions) and make it vary as a function of one of those variables.',
48
+ promptFragment: 'Apply the ASIT operator BREAKING SYMMETRY: propose 3–5 partitions, each turning a currently-uniform property of the system into one that varies with a contextual variable (location, time, user, condition). State the property and the variable it now depends on.',
49
+ },
50
+ ];