@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,23 @@
1
+ // TRIZ family catalog — verbatim duplicate of `src/operators/triz.ts` so the
2
+ // MCP package builds without reaching into the main app's source tree. Derives
3
+ // 40 OperatorDefinitions from the principles table in `triz-matrix.ts`.
4
+ import { TRIZ_PRINCIPLES } from './triz-matrix.js';
5
+ function buildPromptFragment(p) {
6
+ const examples = p.examples && p.examples.length > 0
7
+ ? ` Reference examples from the canonical literature: ${p.examples.join('; ')}.`
8
+ : '';
9
+ return `Apply TRIZ Inventive Principle #${p.num} — ${p.name}. Doctrine: ${p.doctrine}${examples} Propose 3–5 concrete partitions of the current concept that each embody this principle. For every partition, name precisely what is being transformed (subject), how the principle reshapes it (mechanism), and what new behavior or affordance results (consequence). Avoid vague restatements; every partition must be implementable.`;
10
+ }
11
+ export const TRIZ_OPERATORS = TRIZ_PRINCIPLES.map((p) => ({
12
+ family: 'TRIZ',
13
+ key: `principle_${String(p.num).padStart(2, '0')}_${p.key}`,
14
+ name: p.name,
15
+ glyph: String(p.num).padStart(2, '0'),
16
+ oneLiner: p.oneLiner,
17
+ doctrine: p.doctrine,
18
+ promptFragment: buildPromptFragment(p),
19
+ }));
20
+ export const TRIZ_KEYS_BY_NUMBER = TRIZ_PRINCIPLES.reduce((acc, p, i) => {
21
+ acc[p.num] = TRIZ_OPERATORS[i].key;
22
+ return acc;
23
+ }, {});
@@ -0,0 +1,10 @@
1
+ // Operator type — narrow copy of `src/types/operators.ts` so the MCP package
2
+ // builds with no reach into the main app's source tree. The shape is
3
+ // intentionally identical; if the webapp's OperatorDefinition gains fields,
4
+ // keep this in sync by hand.
5
+ //
6
+ // `family` is the same string union the webapp uses. We keep it loose (string)
7
+ // here so a future operator family added to the webapp doesn't break the MCP
8
+ // build — the runtime catalog is the source of truth for which families are
9
+ // actually wired up.
10
+ export {};
@@ -0,0 +1,141 @@
1
+ // Prompt composer — port of `src/prompt/compose.ts` adapted to read the MCP's
2
+ // cloud row shapes (snake_case via supabase-js) instead of the webapp's
3
+ // camelCase domain types. The instruction text, response template, and rule
4
+ // list are kept verbatim so the parser's expectations line up.
5
+ //
6
+ // The webapp's composer also blends in a <context> graph-awareness block and
7
+ // a <files> prefix. The MCP composes the bare minimum: brief, ancestry,
8
+ // target, knowledge pool, operator, plus the operator-specific inputs block.
9
+ // File-context retrieval is a separate tool (find_in_files) that ships in
10
+ // Agent B's tool-parity wave; not folded in here.
11
+ const RESPONSE_TEMPLATE = `{
12
+ "partitions": [
13
+ {
14
+ "label": "STANDALONE concept title — 2–5 words, ≤ 60 chars, NO parent prefix, no trailing period",
15
+ "description": "1–2 sentences, ≤ 280 chars",
16
+ "partitionAttribute": "≤ 5 words for the distinguishing attribute",
17
+ "rationale": "1–3 sentences citing the operator and any K used",
18
+ "kReferences": ["k_id_or_empty"],
19
+ "selfCritique": "main weakness or assumption",
20
+ "children": [
21
+ {
22
+ "label": "STANDALONE sub-concept title — same rules; do NOT prefix with this partition's label either",
23
+ "description": "1–2 sentences, ≤ 280 chars",
24
+ "partitionAttribute": "≤ 5 words",
25
+ "rationale": "1–3 sentences",
26
+ "kReferences": [],
27
+ "selfCritique": "main weakness or assumption"
28
+ }
29
+ ]
30
+ }
31
+ ],
32
+ "newKnowledgeProposed": [
33
+ { "title": "fact title", "body": "1–2 sentences", "tags": ["tag1"] }
34
+ ],
35
+ "operatorNotes": "one line on how the operator fit (optional)"
36
+ }`;
37
+ function pathBlock(path) {
38
+ return path
39
+ .map((n, i) => {
40
+ const indent = ' '.repeat(i);
41
+ const head = i === 0 ? 'ROOT' : `LVL ${i}`;
42
+ const attr = n.partition_attribute ? ` attribute: ${n.partition_attribute}` : '';
43
+ const desc = n.description ? `\n${indent} description: ${n.description}` : '';
44
+ return `${indent}- [${head}] ${n.label}${attr}${desc}`;
45
+ })
46
+ .join('\n');
47
+ }
48
+ function knowledgeBlock(knowledge) {
49
+ if (knowledge.length === 0)
50
+ return '(no knowledge items pinned)';
51
+ return knowledge
52
+ .map((k) => `- id=${k.id} tags=[${(k.tags ?? []).join(', ')}] :: ${k.label}\n ${k.description}`)
53
+ .join('\n');
54
+ }
55
+ function inputsBlock(inputs) {
56
+ return inputs
57
+ .map((n) => ` <input id="${n.id}">\n <label>${n.label}</label>${n.description ? `\n <description>${n.description}</description>` : ''}\n </input>`)
58
+ .join('\n');
59
+ }
60
+ function branchBlock(branch) {
61
+ if (branch.existingChildren.length === 0) {
62
+ return `<branch parent="${branch.parentLabel}">\n (no existing children — propose first partitions)\n</branch>`;
63
+ }
64
+ const children = branch.existingChildren
65
+ .map((c) => ` <child>\n <label>${c.label}</label>${c.description ? `\n <desc>${c.description}</desc>` : ''}\n </child>`)
66
+ .join('\n');
67
+ return `<branch parent="${branch.parentLabel}">\n${children}\n</branch>`;
68
+ }
69
+ function contradictionBlock(c) {
70
+ const principles = c.principles
71
+ .map((p) => ` - #${p.num} ${p.name}: ${p.doctrine}`)
72
+ .join('\n');
73
+ return `<contradiction>
74
+ improving: ${c.improvingName}
75
+ worsening: ${c.worseningName}
76
+ matrix_principles:
77
+ ${principles}
78
+ </contradiction>`;
79
+ }
80
+ export function composePrompt(input) {
81
+ const { project, ancestry, target, operator, knowledge, freeformAngle, combineInputs, branch, contradiction, } = input;
82
+ const angleBlock = freeformAngle &&
83
+ (operator.family === 'FREEFORM' ||
84
+ operator.family === 'COMBINE' ||
85
+ operator.family === 'EXPLORE')
86
+ ? `\n<angle>\n${freeformAngle}\n</angle>\n`
87
+ : '';
88
+ const inputsXml = operator.family === 'COMBINE' && combineInputs && combineInputs.length > 0
89
+ ? `\n<inputs>\n${inputsBlock(combineInputs)}\n</inputs>\n`
90
+ : '';
91
+ const branchXml = operator.family === 'EXPLORE' && branch ? `\n${branchBlock(branch)}\n` : '';
92
+ const contradictionXml = operator.family === 'CONTRADICTION' && contradiction
93
+ ? `\n${contradictionBlock(contradiction)}\n`
94
+ : '';
95
+ return `You are assisting an inventive design session structured by C-K theory. The user is growing a graph of concepts (C) drawing on a pool of validated knowledge (K). You will generate a set of new partitions of the TARGET concept by applying the requested operator from ASIT/TRIZ.
96
+
97
+ <brief>
98
+ ${project.brief}
99
+ </brief>
100
+
101
+ <concept_path_root_to_target>
102
+ ${pathBlock(ancestry)}
103
+ </concept_path_root_to_target>
104
+
105
+ <target_concept>
106
+ id: ${target.id}
107
+ label: ${target.label}
108
+ description: ${target.description || '(no description)'}
109
+ notes: ${target.notes || '(none)'}
110
+ </target_concept>
111
+
112
+ <knowledge_pool>
113
+ ${knowledgeBlock(knowledge)}
114
+ </knowledge_pool>
115
+
116
+ <operator>
117
+ family: ${operator.family}
118
+ key: ${operator.key}
119
+ name: ${operator.name}
120
+ doctrine: ${operator.doctrine}
121
+ </operator>
122
+ ${inputsXml}${branchXml}${contradictionXml}${angleBlock}
123
+ <instructions>
124
+ ${operator.promptFragment}
125
+
126
+ Rules:
127
+ - Produce 3–5 partitions at the top level, each genuinely distinct, each adding a clear new attribute to the TARGET concept. (The optional \`children\` array below adds depth-2 nodes; it does NOT count toward the 3–5 top-level requirement.)
128
+ - Labels MUST be STANDALONE concept titles. Do NOT prefix labels with the parent concept's label. For example, if the parent is "Test", do NOT write labels like "Test by destruction" or "Test for X" — just write "Destruction" or "X". The label should make sense on its own; the parent context is implicit from the graph structure. This rule applies to EVERY label in the response, including children (a child's label must not contain its immediate parent partition's label either).
129
+ - Labels MUST be short: 2–5 words, ≤ 60 characters, no trailing punctuation. The label is a concept title, not a sentence. Put long-form prose in description/rationale, not in label.
130
+ - Each partition MAY optionally include a \`children\` array of 1–4 sub-partitions, when the partition naturally decomposes further into a clearly distinct sub-axis. Children follow the same shape (label, description, partitionAttribute, rationale, kReferences, selfCritique). Do NOT nest beyond one level — a child must NEVER have its own \`children\` array. Omit \`children\` entirely when no useful sub-decomposition exists; do not pad.
131
+ - Stay faithful to the operator's doctrine. If the operator forbids alien components (ASIT closed-world), do not introduce them.
132
+ - For each partition, cite by id any knowledge item from <knowledge_pool> you actually used in kReferences. Empty array if none.
133
+ - Use selfCritique to surface the strongest assumption or risk in that partition (do not flatter the idea).
134
+ - If you needed a fact you did not have, propose it via newKnowledgeProposed (1–3 items max). Do NOT invent specific numbers as facts; phrase as questions to verify.
135
+ - Output ONLY a single JSON object, matching this shape exactly. No prose before or after, no markdown fences.
136
+ </instructions>
137
+
138
+ <response_shape>
139
+ ${RESPONSE_TEMPLATE}
140
+ </response_shape>`;
141
+ }
@@ -0,0 +1,99 @@
1
+ // JSON-extraction + schema-validation for LLM operator responses. Verbatim
2
+ // duplicate of `src/prompt/parse.ts` so the MCP can validate provider output
3
+ // without importing from the main app.
4
+ //
5
+ // The scanner tolerates ```json fences and stray prose because providers
6
+ // (especially OpenRouter pass-throughs) sometimes wrap JSON even when
7
+ // response_format=json_object is requested.
8
+ import { llmResponseSchema } from './schema.js';
9
+ const FENCE = /```(?:json)?\s*([\s\S]*?)```/i;
10
+ function stripFence(input) {
11
+ const trimmed = input.trim();
12
+ const m = trimmed.match(FENCE);
13
+ if (m)
14
+ return m[1].trim();
15
+ return trimmed;
16
+ }
17
+ function findJsonObject(input) {
18
+ let depth = 0;
19
+ let start = -1;
20
+ let inString = false;
21
+ let escape = false;
22
+ for (let i = 0; i < input.length; i++) {
23
+ const ch = input[i];
24
+ if (inString) {
25
+ if (escape) {
26
+ escape = false;
27
+ }
28
+ else if (ch === '\\') {
29
+ escape = true;
30
+ }
31
+ else if (ch === '"') {
32
+ inString = false;
33
+ }
34
+ continue;
35
+ }
36
+ if (ch === '"') {
37
+ inString = true;
38
+ continue;
39
+ }
40
+ if (ch === '{') {
41
+ if (depth === 0)
42
+ start = i;
43
+ depth++;
44
+ }
45
+ else if (ch === '}') {
46
+ depth--;
47
+ if (depth === 0 && start !== -1) {
48
+ return { text: input.slice(start, i + 1), truncated: false };
49
+ }
50
+ }
51
+ }
52
+ return { text: null, truncated: start !== -1 };
53
+ }
54
+ export function parseLlmResponse(rawInput) {
55
+ const cleaned = stripFence(rawInput);
56
+ let candidate;
57
+ let truncated = false;
58
+ if (cleaned.startsWith('{')) {
59
+ const scan = findJsonObject(cleaned);
60
+ candidate = scan.text;
61
+ truncated = scan.truncated;
62
+ }
63
+ else {
64
+ const scan = findJsonObject(cleaned);
65
+ candidate = scan.text;
66
+ truncated = scan.truncated;
67
+ }
68
+ if (!candidate) {
69
+ return {
70
+ ok: false,
71
+ error: truncated
72
+ ? 'Response was truncated mid-JSON — likely hit the max-tokens limit. Retry with a higher-tier model.'
73
+ : 'No JSON object found in the model response.',
74
+ raw: rawInput,
75
+ };
76
+ }
77
+ let json;
78
+ try {
79
+ json = JSON.parse(candidate);
80
+ }
81
+ catch (e) {
82
+ return {
83
+ ok: false,
84
+ error: `JSON.parse failed: ${e.message}`,
85
+ raw: rawInput,
86
+ };
87
+ }
88
+ const result = llmResponseSchema.safeParse(json);
89
+ if (!result.success) {
90
+ return {
91
+ ok: false,
92
+ error: `Schema validation failed: ${result.error.issues
93
+ .map((i) => `${i.path.join('.')} — ${i.message}`)
94
+ .join('; ')}`,
95
+ raw: rawInput,
96
+ };
97
+ }
98
+ return { ok: true, data: result.data, raw: rawInput };
99
+ }
@@ -0,0 +1,30 @@
1
+ // LLM response schema — duplicate of `src/prompt/schema.ts` so the MCP can
2
+ // parse operator output without reaching into the main app. Keep in sync if
3
+ // the webapp tightens limits (label ≤ 60, etc.) — the parser will accept
4
+ // either; the webapp is the strict gate at write time.
5
+ import { z } from 'zod';
6
+ const partitionBaseShape = {
7
+ label: z.string().min(1).max(60),
8
+ description: z.string().min(1).max(600),
9
+ partitionAttribute: z.string().min(1).max(80),
10
+ rationale: z.string().min(1).max(800),
11
+ kReferences: z.array(z.string()).default([]),
12
+ selfCritique: z.string().max(600).optional().default(''),
13
+ };
14
+ // Leaf — depth-2 children. No further nesting.
15
+ export const partitionLeafSchema = z.object(partitionBaseShape);
16
+ // Root — depth-1 partitions. May optionally carry up to 4 children.
17
+ export const partitionSchema = z.object({
18
+ ...partitionBaseShape,
19
+ children: z.array(partitionLeafSchema).max(4).optional(),
20
+ });
21
+ export const newKnowledgeSchema = z.object({
22
+ title: z.string().min(1).max(160),
23
+ body: z.string().min(1).max(800),
24
+ tags: z.array(z.string()).default([]),
25
+ });
26
+ export const llmResponseSchema = z.object({
27
+ partitions: z.array(partitionSchema).min(1).max(8),
28
+ newKnowledgeProposed: z.array(newKnowledgeSchema).default([]),
29
+ operatorNotes: z.string().max(400).optional().default(''),
30
+ });
@@ -0,0 +1,192 @@
1
+ // Heuresis MCP - Supabase Realtime CDC subscription (Phase 19.8).
2
+ //
3
+ // We subscribe to row-level changes on the four workspace tables that the
4
+ // webapp also watches: nodes, edges, projects, ideas. When a change lands we
5
+ // fire a single callback so the MCP server can notify its client (Claude
6
+ // Desktop, Claude Code, Cursor, etc.) that the workspace state has moved.
7
+ //
8
+ // One channel per process, filtered by workspace_id. RLS still applies to
9
+ // Realtime payloads, so even if a stray row leaks through the publication a
10
+ // non-member of the workspace would not see it. The filter is belt-and-braces.
11
+ //
12
+ // Reconnect behavior:
13
+ // supabase-js auto-reconnects on transient socket drops. We hook the
14
+ // subscribe-status callback and re-emit a "resync" event on every
15
+ // SUBSCRIBED transition that follows a CLOSED / CHANNEL_ERROR / TIMED_OUT
16
+ // state, so the client knows it may have missed updates while offline and
17
+ // should refetch.
18
+ //
19
+ // CLI flag:
20
+ // The default is ON. Users can opt out with `--no-realtime` (one-shot for
21
+ // this process) or persistently by setting `{ realtime: false }` in
22
+ // ~/.heuresis/config.json. The CLI flag wins over the config file when both
23
+ // are present.
24
+ import { existsSync } from 'node:fs';
25
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
26
+ import { homedir } from 'node:os';
27
+ import { dirname, join } from 'node:path';
28
+ const TABLES = ['nodes', 'edges', 'projects', 'ideas'];
29
+ // ---------------------------------------------------------------------------
30
+ // Subscription
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Subscribe to Postgres change events on the user's workspace and call
34
+ * `onChange` for every row change (INSERT / UPDATE / DELETE) and for every
35
+ * post-reconnect resync signal.
36
+ *
37
+ * Returns an `unsubscribe` function that tears down the channel. Safe to call
38
+ * more than once; subsequent calls are no-ops.
39
+ */
40
+ export function startRealtimeSubscription(client, workspaceId, onChange) {
41
+ const channelName = `heuresis-mcp-ws-${workspaceId}`;
42
+ const channel = client.channel(channelName);
43
+ let everSubscribed = false;
44
+ let lastStatusWasBad = false;
45
+ for (const table of TABLES) {
46
+ channel.on(
47
+ // The supabase-js types for `channel.on('postgres_changes', ...)` are
48
+ // strictly typed against a string-literal overload. Casting through
49
+ // `any` keeps this file readable while still matching the runtime API.
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ 'postgres_changes', {
52
+ event: '*',
53
+ schema: 'public',
54
+ table,
55
+ filter: `workspace_id=eq.${workspaceId}`,
56
+ },
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ (payload) => {
59
+ const newRow = payload && typeof payload.new === 'object' && payload.new
60
+ ? payload.new
61
+ : null;
62
+ const oldRow = payload && typeof payload.old === 'object' && payload.old
63
+ ? payload.old
64
+ : null;
65
+ const eventType = payload?.eventType === 'INSERT' || payload?.eventType === 'UPDATE' || payload?.eventType === 'DELETE'
66
+ ? payload.eventType
67
+ : 'UPDATE';
68
+ try {
69
+ onChange({ table, eventType, new: newRow, old: oldRow });
70
+ }
71
+ catch (err) {
72
+ console.error('[heuresis-mcp] realtime handler threw:', err);
73
+ }
74
+ });
75
+ }
76
+ channel.subscribe((status, err) => {
77
+ // status is one of SUBSCRIBED | TIMED_OUT | CLOSED | CHANNEL_ERROR.
78
+ if (status === 'SUBSCRIBED') {
79
+ if (everSubscribed && lastStatusWasBad) {
80
+ // Reconnect: tell the caller it may have missed events while the
81
+ // socket was down.
82
+ try {
83
+ onChange({ table: null, eventType: 'RESYNC', new: null, old: null });
84
+ }
85
+ catch (handlerErr) {
86
+ console.error('[heuresis-mcp] realtime resync handler threw:', handlerErr);
87
+ }
88
+ console.error(`[heuresis-mcp] realtime: reconnected to workspace ${workspaceId} (possible missed updates).`);
89
+ }
90
+ else {
91
+ console.error(`[heuresis-mcp] realtime: subscribed to workspace ${workspaceId} (tables: ${TABLES.join(', ')}).`);
92
+ }
93
+ everSubscribed = true;
94
+ lastStatusWasBad = false;
95
+ }
96
+ else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
97
+ lastStatusWasBad = true;
98
+ const detail = err ? `: ${err.message}` : '';
99
+ console.error(`[heuresis-mcp] realtime: channel ${status}${detail}. supabase-js will retry.`);
100
+ }
101
+ });
102
+ let torn = false;
103
+ return () => {
104
+ if (torn)
105
+ return;
106
+ torn = true;
107
+ try {
108
+ void client.removeChannel(channel);
109
+ }
110
+ catch (err) {
111
+ console.error('[heuresis-mcp] realtime: removeChannel failed:', err);
112
+ }
113
+ };
114
+ }
115
+ /**
116
+ * Resolve the workspace the MCP session should subscribe to. Same single-
117
+ * workspace rule the cloud tools use: first workspace visible to the user,
118
+ * ordered by name. A future per-session override (workspace id in
119
+ * credentials.json or an env var) can replace this.
120
+ */
121
+ export async function resolveSubscriptionWorkspaceId(client) {
122
+ const res = await client
123
+ .from('workspaces')
124
+ .select('id, name')
125
+ .order('name', { ascending: true })
126
+ .limit(1);
127
+ if (res.error) {
128
+ console.error(`[heuresis-mcp] realtime: failed to resolve workspace: ${res.error.message}`);
129
+ return null;
130
+ }
131
+ const rows = (res.data ?? []);
132
+ return rows.length > 0 ? rows[0].id : null;
133
+ }
134
+ export function configPath() {
135
+ return join(homedir(), '.heuresis', 'config.json');
136
+ }
137
+ export async function readConfig() {
138
+ const path = configPath();
139
+ if (!existsSync(path))
140
+ return {};
141
+ try {
142
+ const text = await readFile(path, 'utf8');
143
+ const data = JSON.parse(text);
144
+ return data && typeof data === 'object' ? data : {};
145
+ }
146
+ catch {
147
+ return {};
148
+ }
149
+ }
150
+ export async function writeConfig(next) {
151
+ const path = configPath();
152
+ await mkdir(dirname(path), { recursive: true });
153
+ const current = await readConfig();
154
+ const merged = { ...current, ...next };
155
+ await writeFile(path, JSON.stringify(merged, null, 2), 'utf8');
156
+ return path;
157
+ }
158
+ /**
159
+ * Read the effective "should Realtime be on?" decision from (in order):
160
+ * 1. The CLI flag `--no-realtime` (or `--realtime` to force on).
161
+ * 2. The `realtime` field in ~/.heuresis/config.json.
162
+ * 3. Default: true.
163
+ *
164
+ * Also persists a CLI flag back to the config file so the preference sticks
165
+ * across runs (the user only has to pass `--no-realtime` once).
166
+ */
167
+ export async function readRealtimeFlag(argv = process.argv) {
168
+ const hasOff = argv.includes('--no-realtime');
169
+ const hasOn = argv.includes('--realtime');
170
+ if (hasOff && hasOn) {
171
+ console.error('[heuresis-mcp] both --no-realtime and --realtime passed; --no-realtime wins.');
172
+ }
173
+ if (hasOff) {
174
+ await writeConfig({ realtime: false });
175
+ return false;
176
+ }
177
+ if (hasOn) {
178
+ await writeConfig({ realtime: true });
179
+ return true;
180
+ }
181
+ const cfg = await readConfig();
182
+ if (cfg.realtime === false)
183
+ return false;
184
+ return true;
185
+ }
186
+ /**
187
+ * Strip realtime-related flags from argv so the subcommand dispatch in
188
+ * index.ts does not mistake them for an unknown subcommand.
189
+ */
190
+ export function stripRealtimeFlags(argv) {
191
+ return argv.filter((a) => a !== '--no-realtime' && a !== '--realtime');
192
+ }
package/dist/store.js ADDED
@@ -0,0 +1,128 @@
1
+ // In-memory Heuresis store backed by a JSON snapshot on disk.
2
+ //
3
+ // Re-reads the file at every tool call if its mtime has changed, so the
4
+ // agent always sees the latest export without needing a server restart.
5
+ // The file path is taken from $HEURESIS_SNAPSHOT (preferred) or, when
6
+ // absent, the conventional location `~/.heuresis/snapshot.json`.
7
+ //
8
+ // Why this shape:
9
+ // • The web app stores everything in IndexedDB, which Node can't read.
10
+ // • The app's existing Export workspace action writes a JSON file in
11
+ // exactly this shape; the eventual Settings → MCP sync (next commit)
12
+ // will write the same file automatically to a fixed path.
13
+ // • This decouples the MCP server from the browser entirely. The MCP
14
+ // server runs in the agent's process (Claude Desktop, Cursor, …) and
15
+ // reads whatever the latest snapshot has — no IPC, no permission
16
+ // dance, no auth.
17
+ import { readFile, stat } from 'node:fs/promises';
18
+ import { existsSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ export class HeuresisStore {
22
+ path;
23
+ lastMtimeMs = null;
24
+ cache = null;
25
+ constructor(opts = {}) {
26
+ this.path =
27
+ opts.path ??
28
+ process.env.HEURESIS_SNAPSHOT ??
29
+ join(homedir(), '.heuresis', 'snapshot.json');
30
+ }
31
+ getSnapshotPath() {
32
+ return this.path;
33
+ }
34
+ /**
35
+ * Lazy-load and cache the snapshot. Re-reads from disk if the file's
36
+ * mtime has advanced — the agent always sees the latest data without
37
+ * us having to manage file watches.
38
+ */
39
+ async load() {
40
+ if (!existsSync(this.path)) {
41
+ throw new Error(`Heuresis snapshot not found at ${this.path}. Export your workspace from Settings → Workspace → Export, then either set HEURESIS_SNAPSHOT to point at it, or place it at the default path.`);
42
+ }
43
+ const s = await stat(this.path);
44
+ if (this.cache && this.lastMtimeMs === s.mtimeMs) {
45
+ return this.cache;
46
+ }
47
+ const text = await readFile(this.path, 'utf8');
48
+ const data = JSON.parse(text);
49
+ if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) {
50
+ throw new Error(`Snapshot at ${this.path} is missing required nodes/edges arrays. Re-export from Settings.`);
51
+ }
52
+ this.cache = data;
53
+ this.lastMtimeMs = s.mtimeMs;
54
+ return data;
55
+ }
56
+ // ── Convenience accessors ────────────────────────────────────────────
57
+ async workspaces() {
58
+ return (await this.load()).workspaces;
59
+ }
60
+ async nodes() {
61
+ return (await this.load()).nodes;
62
+ }
63
+ async edges() {
64
+ return (await this.load()).edges;
65
+ }
66
+ async projects() {
67
+ return (await this.load()).projects;
68
+ }
69
+ async ideas() {
70
+ return (await this.load()).ideas;
71
+ }
72
+ async provenance() {
73
+ return (await this.load()).provenance ?? [];
74
+ }
75
+ async nodeById(id) {
76
+ return (await this.nodes()).find((n) => n.id === id);
77
+ }
78
+ async projectById(id) {
79
+ return (await this.projects()).find((p) => p.id === id);
80
+ }
81
+ /**
82
+ * Children of a node via partition edges + the canonical `parentId`
83
+ * field. We accept both because the app sometimes records parent
84
+ * relationships only on the node and sometimes only on the edge.
85
+ */
86
+ async childrenOf(id) {
87
+ const [nodes, edges] = await Promise.all([this.nodes(), this.edges()]);
88
+ const ids = new Set();
89
+ for (const e of edges) {
90
+ if (e.kind === 'partition' && e.fromId === id)
91
+ ids.add(e.toId);
92
+ }
93
+ for (const n of nodes) {
94
+ if (n.parentId === id)
95
+ ids.add(n.id);
96
+ }
97
+ return nodes.filter((n) => ids.has(n.id));
98
+ }
99
+ /**
100
+ * Walk descendants breadth-first up to `maxDepth`. Depth 0 = just the
101
+ * node itself, depth 1 = node + direct children, etc.
102
+ */
103
+ async descendantsOf(id, maxDepth) {
104
+ const out = [];
105
+ const seen = new Set();
106
+ let frontier = [id];
107
+ for (let d = 0; d <= maxDepth; d++) {
108
+ const next = [];
109
+ for (const cur of frontier) {
110
+ if (seen.has(cur))
111
+ continue;
112
+ seen.add(cur);
113
+ const node = await this.nodeById(cur);
114
+ if (!node)
115
+ continue;
116
+ out.push(node);
117
+ if (d < maxDepth) {
118
+ const kids = await this.childrenOf(cur);
119
+ for (const k of kids)
120
+ if (!seen.has(k.id))
121
+ next.push(k.id);
122
+ }
123
+ }
124
+ frontier = next;
125
+ }
126
+ return out;
127
+ }
128
+ }