@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.
- package/README.md +152 -0
- package/dist/cli.js +276 -0
- package/dist/cloudClient.js +71 -0
- package/dist/cloudOperators.js +530 -0
- package/dist/cloudTools.js +1727 -0
- package/dist/cloudTypes.js +8 -0
- package/dist/credentials.js +97 -0
- package/dist/index.js +294 -0
- package/dist/llm/client.js +155 -0
- package/dist/llm/cost.js +65 -0
- package/dist/operators/asit.js +50 -0
- package/dist/operators/combine.js +10 -0
- package/dist/operators/contradiction.js +13 -0
- package/dist/operators/explore.js +14 -0
- package/dist/operators/triz-matrix.js +1964 -0
- package/dist/operators/triz.js +23 -0
- package/dist/operators/types.js +10 -0
- package/dist/prompt/compose.js +141 -0
- package/dist/prompt/parse.js +99 -0
- package/dist/prompt/schema.js +30 -0
- package/dist/realtime.js +192 -0
- package/dist/store.js +128 -0
- package/dist/tools.js +264 -0
- package/dist/types.js +5 -0
- package/dist/zod-to-json-schema.js +89 -0
- package/package.json +54 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// Heuresis MCP — operator-running tools (Phase 19.5).
|
|
2
|
+
//
|
|
3
|
+
// Turns the MCP from a "data layer" into a "thinking partner": the user / a
|
|
4
|
+
// host agent can fire a Branch / Matrix / ASIT / TRIZ / Free / Combine /
|
|
5
|
+
// Contradiction operator directly from the MCP, against the same cloud
|
|
6
|
+
// workspace the webapp sees. The MCP loads the user's BYO key via the
|
|
7
|
+
// `get_my_provider_key` SECURITY DEFINER RPC, calls the provider directly,
|
|
8
|
+
// parses the JSON envelope through the same schema the webapp uses, and
|
|
9
|
+
// returns structured candidates.
|
|
10
|
+
//
|
|
11
|
+
// Two surfaces:
|
|
12
|
+
//
|
|
13
|
+
// * run_operator(family, key, anchor_id, args?) — generates candidates
|
|
14
|
+
// and (by default) does NOT commit. The caller decides whether to use
|
|
15
|
+
// `bulk_add_concepts` (Agent B's tool) to commit, or to call the sibling
|
|
16
|
+
// `run_operator_and_commit` to do it in one tool round-trip.
|
|
17
|
+
//
|
|
18
|
+
// * expand_concept(id, depth, breadth, angle?) — recursive Branch. Walks
|
|
19
|
+
// breadth-first and commits each level immediately so the user sees
|
|
20
|
+
// partial results in the webapp as the run progresses (rather than
|
|
21
|
+
// waiting 30s for a full tree). Hard-capped at depth * breadth ≤ 60 per
|
|
22
|
+
// PLAN.md Phase 10.3 safety guardrail.
|
|
23
|
+
//
|
|
24
|
+
// Provenance — every committed concept gets a row in `public.provenance`
|
|
25
|
+
// stamped origin='mcp', operator='<family>:<key>', sourceRefs=[anchor_id].
|
|
26
|
+
// The migration that lands the table is 0015 (Agent B's half).
|
|
27
|
+
//
|
|
28
|
+
// Cost preview — every run_operator response carries an `estimated_cost:
|
|
29
|
+
// { credits, dollars }` chip from the rate card in `docs/credits.md` §2.
|
|
30
|
+
// Informational only; BYO-key runs don't bill against managed credits.
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { unwrap } from './cloudClient.js';
|
|
33
|
+
import { ASIT_OPERATORS } from './operators/asit.js';
|
|
34
|
+
import { TRIZ_OPERATORS } from './operators/triz.js';
|
|
35
|
+
import { CONTRADICTION_OPERATOR } from './operators/contradiction.js';
|
|
36
|
+
import { COMBINE_OPERATOR } from './operators/combine.js';
|
|
37
|
+
import { EXPLORE_OPERATOR } from './operators/explore.js';
|
|
38
|
+
import { TRIZ_PARAMETERS, TRIZ_PRINCIPLES, lookupPrinciples, } from './operators/triz-matrix.js';
|
|
39
|
+
import { composePrompt } from './prompt/compose.js';
|
|
40
|
+
import { parseLlmResponse } from './prompt/parse.js';
|
|
41
|
+
import { defaultModelFor, runLlm, } from './llm/client.js';
|
|
42
|
+
import { estimateCost } from './llm/cost.js';
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// FREEFORM operator — referenced by run_operator(family='free') and reused
|
|
45
|
+
// internally by expand_concept. Defined inline rather than in
|
|
46
|
+
// `operators/free.ts` because it's a single value and the webapp lives it in
|
|
47
|
+
// `operators/catalog.ts`; folding it here keeps the operator file count low.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const FREEFORM_OPERATOR = {
|
|
50
|
+
family: 'FREEFORM',
|
|
51
|
+
key: 'freeform',
|
|
52
|
+
name: 'Free expansion',
|
|
53
|
+
glyph: '✎',
|
|
54
|
+
oneLiner: 'Expand the concept along a user-supplied angle.',
|
|
55
|
+
doctrine: 'No fixed heuristic. The user supplies an angle or question and the LLM proposes partitions consistent with that angle.',
|
|
56
|
+
promptFragment: 'Apply the FREEFORM operator: propose 3–5 partitions that expand the current concept along the angle stated by the user (see <angle> tag below).',
|
|
57
|
+
};
|
|
58
|
+
const ALL_OPERATORS = [
|
|
59
|
+
...ASIT_OPERATORS,
|
|
60
|
+
...TRIZ_OPERATORS,
|
|
61
|
+
CONTRADICTION_OPERATOR,
|
|
62
|
+
FREEFORM_OPERATOR,
|
|
63
|
+
COMBINE_OPERATOR,
|
|
64
|
+
EXPLORE_OPERATOR,
|
|
65
|
+
];
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Family-key resolution
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// `run_operator` accepts `family` as a low-case string ('asit' | 'triz' |
|
|
70
|
+
// 'contradiction' | 'free' | 'combine') for caller convenience; the webapp
|
|
71
|
+
// uses UPPER (OperatorDefinition.family). Normalize once.
|
|
72
|
+
const FAMILY_ALIASES = {
|
|
73
|
+
asit: 'ASIT',
|
|
74
|
+
triz: 'TRIZ',
|
|
75
|
+
contradiction: 'CONTRADICTION',
|
|
76
|
+
free: 'FREEFORM',
|
|
77
|
+
freeform: 'FREEFORM',
|
|
78
|
+
combine: 'COMBINE',
|
|
79
|
+
explore: 'EXPLORE',
|
|
80
|
+
branch: 'EXPLORE',
|
|
81
|
+
};
|
|
82
|
+
function normalizeFamily(raw) {
|
|
83
|
+
const lower = raw.toLowerCase();
|
|
84
|
+
return FAMILY_ALIASES[lower] ?? null;
|
|
85
|
+
}
|
|
86
|
+
function resolveOperator(family, key) {
|
|
87
|
+
// EXPLORE / FREEFORM / COMBINE / CONTRADICTION ignore `key` — there's
|
|
88
|
+
// exactly one operator per family. Default to that.
|
|
89
|
+
if (family === 'EXPLORE')
|
|
90
|
+
return EXPLORE_OPERATOR;
|
|
91
|
+
if (family === 'FREEFORM')
|
|
92
|
+
return FREEFORM_OPERATOR;
|
|
93
|
+
if (family === 'COMBINE')
|
|
94
|
+
return COMBINE_OPERATOR;
|
|
95
|
+
if (family === 'CONTRADICTION')
|
|
96
|
+
return CONTRADICTION_OPERATOR;
|
|
97
|
+
// ASIT + TRIZ need a key. Match exactly.
|
|
98
|
+
return (ALL_OPERATORS.find((o) => o.family === family && o.key === key) ?? null);
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Anchor + project + ancestry loaders
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
async function loadAnchor(client, id) {
|
|
104
|
+
const res = await client.from('nodes').select('*').eq('id', id).maybeSingle();
|
|
105
|
+
if (res.error)
|
|
106
|
+
throw new Error(res.error.message);
|
|
107
|
+
if (!res.data)
|
|
108
|
+
throw new Error(`No concept with id ${id}`);
|
|
109
|
+
return res.data;
|
|
110
|
+
}
|
|
111
|
+
async function loadProjectForNode(client, node) {
|
|
112
|
+
// Prefer the denormalized project_id on the node; fall back to a join
|
|
113
|
+
// through project_nodes (legacy rows pre-migration 0012 lacked the column).
|
|
114
|
+
let projectId = node.project_id;
|
|
115
|
+
if (!projectId) {
|
|
116
|
+
const rows = unwrap(await client
|
|
117
|
+
.from('project_nodes')
|
|
118
|
+
.select('project_id')
|
|
119
|
+
.eq('node_id', node.id)
|
|
120
|
+
.limit(1));
|
|
121
|
+
projectId = rows[0]?.project_id ?? null;
|
|
122
|
+
}
|
|
123
|
+
if (!projectId) {
|
|
124
|
+
throw new Error(`Concept ${node.id} is not in any project — operators need a project context to compose prompts.`);
|
|
125
|
+
}
|
|
126
|
+
const proj = unwrap(await client.from('projects').select('*').eq('id', projectId).single());
|
|
127
|
+
return proj;
|
|
128
|
+
}
|
|
129
|
+
async function loadAncestry(client, node) {
|
|
130
|
+
// Walk parent_id up to the root. Cheap because depth is small.
|
|
131
|
+
const chain = [node];
|
|
132
|
+
const seen = new Set([node.id]);
|
|
133
|
+
let cur = node.parent_id;
|
|
134
|
+
while (cur && !seen.has(cur)) {
|
|
135
|
+
seen.add(cur);
|
|
136
|
+
const res = await client.from('nodes').select('*').eq('id', cur).maybeSingle();
|
|
137
|
+
if (res.error)
|
|
138
|
+
break;
|
|
139
|
+
const p = res.data;
|
|
140
|
+
if (!p)
|
|
141
|
+
break;
|
|
142
|
+
chain.unshift(p);
|
|
143
|
+
cur = p.parent_id;
|
|
144
|
+
}
|
|
145
|
+
return chain;
|
|
146
|
+
}
|
|
147
|
+
async function loadKnowledgePool(client, project) {
|
|
148
|
+
const memberRows = unwrap(await client
|
|
149
|
+
.from('project_nodes')
|
|
150
|
+
.select('node_id')
|
|
151
|
+
.eq('project_id', project.id));
|
|
152
|
+
const memberIds = memberRows.map((r) => r.node_id);
|
|
153
|
+
if (memberIds.length === 0)
|
|
154
|
+
return [];
|
|
155
|
+
const rows = unwrap(await client
|
|
156
|
+
.from('nodes')
|
|
157
|
+
.select('*')
|
|
158
|
+
.in('id', memberIds)
|
|
159
|
+
.eq('status', 'validated'));
|
|
160
|
+
// Cap at 20 to keep prompt bloat bounded; same heuristic the webapp uses.
|
|
161
|
+
return rows.slice(0, 20);
|
|
162
|
+
}
|
|
163
|
+
async function loadDirectChildren(client, node) {
|
|
164
|
+
const rows = unwrap(await client
|
|
165
|
+
.from('nodes')
|
|
166
|
+
.select('*')
|
|
167
|
+
.eq('parent_id', node.id)
|
|
168
|
+
.neq('status', 'archived'));
|
|
169
|
+
return rows;
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// LLM config resolution — reads localStorage-style provider/model selection
|
|
173
|
+
// from credentials.json (added in a tiny extension) OR defaults to anthropic
|
|
174
|
+
// + sonnet. The key itself comes from the RPC.
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
async function resolveLlmConfig(client, preferProvider) {
|
|
177
|
+
const provider = preferProvider ?? 'anthropic';
|
|
178
|
+
const rpcRes = await client.rpc('get_my_provider_key', {
|
|
179
|
+
p_provider: provider,
|
|
180
|
+
});
|
|
181
|
+
if (rpcRes.error) {
|
|
182
|
+
throw new Error(`Failed to load BYO key via get_my_provider_key: ${rpcRes.error.message}`);
|
|
183
|
+
}
|
|
184
|
+
const apiKey = rpcRes.data ?? '';
|
|
185
|
+
if (!apiKey) {
|
|
186
|
+
throw new Error(`No provider key configured for "${provider}". Add one in Settings ▸ AI service (in the webapp) or pass a different provider via args.provider.`);
|
|
187
|
+
}
|
|
188
|
+
return { provider, apiKey, model: defaultModelFor(provider) };
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// run_operator
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
const ProviderEnum = z.enum(['anthropic', 'openai', 'openrouter', 'google']);
|
|
194
|
+
export const runOperatorInput = z
|
|
195
|
+
.object({
|
|
196
|
+
family: z
|
|
197
|
+
.string()
|
|
198
|
+
.describe("Operator family. One of 'asit' | 'triz' | 'contradiction' | 'free' | 'combine' | 'explore'."),
|
|
199
|
+
key: z
|
|
200
|
+
.string()
|
|
201
|
+
.describe("Operator key within the family. Ignored for single-operator families (free, combine, contradiction, explore). For ASIT use one of: unification, multiplication, division, object_removal, breaking_symmetry. For TRIZ use 'principle_NN_<snake_name>' (e.g. principle_01_segmentation)."),
|
|
202
|
+
anchor_id: z.string().describe('The concept the operator runs against.'),
|
|
203
|
+
args: z
|
|
204
|
+
.record(z.unknown())
|
|
205
|
+
.optional()
|
|
206
|
+
.describe("Family-specific extras: { angle?: string } for free/explore/combine, { improving: number, worsening: number } for contradiction, { combineWithIds: string[] } for combine. Optional { provider: 'anthropic' | 'openai' | 'openrouter' | 'google' } overrides the default provider."),
|
|
207
|
+
})
|
|
208
|
+
.strict();
|
|
209
|
+
export async function runOperator(client, args) {
|
|
210
|
+
const family = normalizeFamily(args.family);
|
|
211
|
+
if (!family) {
|
|
212
|
+
throw new Error(`Unknown operator family "${args.family}". Use one of: asit, triz, contradiction, free, combine, explore.`);
|
|
213
|
+
}
|
|
214
|
+
const operator = resolveOperator(family, args.key);
|
|
215
|
+
if (!operator) {
|
|
216
|
+
throw new Error(`Unknown operator key "${args.key}" for family ${family}.`);
|
|
217
|
+
}
|
|
218
|
+
const anchor = await loadAnchor(client, args.anchor_id);
|
|
219
|
+
const project = await loadProjectForNode(client, anchor);
|
|
220
|
+
const [ancestry, knowledge] = await Promise.all([
|
|
221
|
+
loadAncestry(client, anchor),
|
|
222
|
+
loadKnowledgePool(client, project),
|
|
223
|
+
]);
|
|
224
|
+
// Family-specific extras.
|
|
225
|
+
const extras = args.args ?? {};
|
|
226
|
+
const angle = typeof extras.angle === 'string' ? extras.angle : undefined;
|
|
227
|
+
let branch;
|
|
228
|
+
if (family === 'EXPLORE') {
|
|
229
|
+
const kids = await loadDirectChildren(client, anchor);
|
|
230
|
+
branch = {
|
|
231
|
+
parentLabel: anchor.label,
|
|
232
|
+
existingChildren: kids.map((k) => ({
|
|
233
|
+
label: k.label,
|
|
234
|
+
description: k.description,
|
|
235
|
+
})),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
let contradiction;
|
|
239
|
+
if (family === 'CONTRADICTION') {
|
|
240
|
+
const improving = Number(extras.improving);
|
|
241
|
+
const worsening = Number(extras.worsening);
|
|
242
|
+
if (!Number.isInteger(improving) || improving < 1 || improving > 39) {
|
|
243
|
+
throw new Error("CONTRADICTION requires args.improving (1..39 — TRIZ parameter number).");
|
|
244
|
+
}
|
|
245
|
+
if (!Number.isInteger(worsening) || worsening < 1 || worsening > 39) {
|
|
246
|
+
throw new Error("CONTRADICTION requires args.worsening (1..39 — TRIZ parameter number).");
|
|
247
|
+
}
|
|
248
|
+
const improvingParam = TRIZ_PARAMETERS.find((p) => p.num === improving);
|
|
249
|
+
const worseningParam = TRIZ_PARAMETERS.find((p) => p.num === worsening);
|
|
250
|
+
const principleNums = lookupPrinciples(improving, worsening).slice(0, 5);
|
|
251
|
+
const principles = principleNums
|
|
252
|
+
.map((n) => TRIZ_PRINCIPLES.find((p) => p.num === n))
|
|
253
|
+
.filter((p) => !!p)
|
|
254
|
+
.map((p) => ({ num: p.num, name: p.name, doctrine: p.doctrine }));
|
|
255
|
+
contradiction = {
|
|
256
|
+
improvingName: improvingParam.name,
|
|
257
|
+
worseningName: worseningParam.name,
|
|
258
|
+
principles,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
let combineInputs;
|
|
262
|
+
if (family === 'COMBINE') {
|
|
263
|
+
const ids = Array.isArray(extras.combineWithIds)
|
|
264
|
+
? extras.combineWithIds.filter((x) => typeof x === 'string')
|
|
265
|
+
: [];
|
|
266
|
+
if (ids.length === 0) {
|
|
267
|
+
throw new Error('COMBINE requires args.combineWithIds: a non-empty array of concept ids to fuse with the anchor.');
|
|
268
|
+
}
|
|
269
|
+
const rows = unwrap(await client
|
|
270
|
+
.from('nodes')
|
|
271
|
+
.select('id, label, description')
|
|
272
|
+
.in('id', [anchor.id, ...ids]));
|
|
273
|
+
combineInputs = rows;
|
|
274
|
+
}
|
|
275
|
+
// Compose + resolve the BYO key.
|
|
276
|
+
const prompt = composePrompt({
|
|
277
|
+
project,
|
|
278
|
+
ancestry,
|
|
279
|
+
target: anchor,
|
|
280
|
+
operator,
|
|
281
|
+
knowledge,
|
|
282
|
+
freeformAngle: angle,
|
|
283
|
+
combineInputs,
|
|
284
|
+
branch,
|
|
285
|
+
contradiction,
|
|
286
|
+
});
|
|
287
|
+
const preferProvider = ProviderEnum.safeParse(extras.provider).success
|
|
288
|
+
? extras.provider
|
|
289
|
+
: undefined;
|
|
290
|
+
const config = await resolveLlmConfig(client, preferProvider);
|
|
291
|
+
const cost = estimateCost({
|
|
292
|
+
provider: config.provider,
|
|
293
|
+
model: config.model,
|
|
294
|
+
promptChars: prompt.length,
|
|
295
|
+
});
|
|
296
|
+
const llmResult = await runLlm(config, { prompt });
|
|
297
|
+
const parsed = parseLlmResponse(llmResult.text);
|
|
298
|
+
if (!parsed.ok) {
|
|
299
|
+
throw new Error(`Operator run produced output the parser rejected: ${parsed.error}`);
|
|
300
|
+
}
|
|
301
|
+
const candidates = parsed.data.partitions.map((p) => ({
|
|
302
|
+
label: p.label,
|
|
303
|
+
description: p.description,
|
|
304
|
+
partitionAttribute: p.partitionAttribute,
|
|
305
|
+
rationale: p.rationale,
|
|
306
|
+
kReferences: p.kReferences,
|
|
307
|
+
selfCritique: p.selfCritique,
|
|
308
|
+
children: p.children,
|
|
309
|
+
}));
|
|
310
|
+
return {
|
|
311
|
+
family,
|
|
312
|
+
key: operator.key,
|
|
313
|
+
anchorId: anchor.id,
|
|
314
|
+
candidates,
|
|
315
|
+
newKnowledgeProposed: parsed.data.newKnowledgeProposed,
|
|
316
|
+
operatorNotes: parsed.data.operatorNotes,
|
|
317
|
+
estimatedCost: {
|
|
318
|
+
credits: cost.credits,
|
|
319
|
+
dollars: cost.dollars,
|
|
320
|
+
modelClass: cost.modelClass,
|
|
321
|
+
},
|
|
322
|
+
rawTextPreview: llmResult.text.slice(0, 5000),
|
|
323
|
+
usage: llmResult.usage,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// run_operator_and_commit — convenience wrapper that fires run_operator,
|
|
328
|
+
// then writes every top-level candidate as a child of the anchor, with a
|
|
329
|
+
// partition edge + provenance row. Children-of-children are NOT committed
|
|
330
|
+
// here (the depth-2 leaves are kept in the response for the caller to act on
|
|
331
|
+
// separately) because mixing depth-2 commits with the depth-1 set has
|
|
332
|
+
// surprised users in webapp testing.
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
export const runOperatorAndCommitInput = runOperatorInput;
|
|
335
|
+
export async function runOperatorAndCommit(client, args) {
|
|
336
|
+
const run = await runOperator(client, args);
|
|
337
|
+
const anchor = await loadAnchor(client, args.anchor_id);
|
|
338
|
+
const project = await loadProjectForNode(client, anchor);
|
|
339
|
+
const ids = [];
|
|
340
|
+
for (const cand of run.candidates) {
|
|
341
|
+
const id = await commitCandidate(client, {
|
|
342
|
+
anchor,
|
|
343
|
+
project,
|
|
344
|
+
label: cand.label,
|
|
345
|
+
description: cand.description,
|
|
346
|
+
operator: `${run.family}:${run.key}`,
|
|
347
|
+
});
|
|
348
|
+
ids.push(id);
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
family: run.family,
|
|
352
|
+
key: run.key,
|
|
353
|
+
anchorId: run.anchorId,
|
|
354
|
+
committedIds: ids,
|
|
355
|
+
estimatedCost: run.estimatedCost,
|
|
356
|
+
operatorNotes: run.operatorNotes,
|
|
357
|
+
usage: run.usage,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
async function commitCandidate(client, args) {
|
|
361
|
+
const inserted = unwrap(await client
|
|
362
|
+
.from('nodes')
|
|
363
|
+
.insert({
|
|
364
|
+
workspace_id: args.anchor.workspace_id,
|
|
365
|
+
parent_id: args.anchor.id,
|
|
366
|
+
label: args.label,
|
|
367
|
+
description: args.description,
|
|
368
|
+
project_id: args.project.id,
|
|
369
|
+
tags: [],
|
|
370
|
+
})
|
|
371
|
+
.select('id')
|
|
372
|
+
.single());
|
|
373
|
+
// Partition edge so the canvas draws the parent → child line.
|
|
374
|
+
await client
|
|
375
|
+
.from('edges')
|
|
376
|
+
.insert({
|
|
377
|
+
workspace_id: args.anchor.workspace_id,
|
|
378
|
+
from_id: args.anchor.id,
|
|
379
|
+
to_id: inserted.id,
|
|
380
|
+
kind: 'partition',
|
|
381
|
+
})
|
|
382
|
+
.select('id')
|
|
383
|
+
.maybeSingle();
|
|
384
|
+
// project_nodes link (the project's member list).
|
|
385
|
+
await client
|
|
386
|
+
.from('project_nodes')
|
|
387
|
+
.insert({ project_id: args.project.id, node_id: inserted.id, position: 0 })
|
|
388
|
+
.select('id')
|
|
389
|
+
.maybeSingle();
|
|
390
|
+
// Provenance row — origin='mcp', operator='<family>:<key>', anchor as
|
|
391
|
+
// source_ref. Best-effort: if the table is absent on a not-yet-migrated
|
|
392
|
+
// database (0015 not applied), swallow the error rather than fail the
|
|
393
|
+
// whole commit — the concept itself is still useful.
|
|
394
|
+
try {
|
|
395
|
+
await client.from('provenance').insert({
|
|
396
|
+
workspace_id: args.anchor.workspace_id,
|
|
397
|
+
node_id: inserted.id,
|
|
398
|
+
origin: 'mcp',
|
|
399
|
+
operator_key: args.operator,
|
|
400
|
+
source_refs: [args.anchor.id],
|
|
401
|
+
created_by: 'agent',
|
|
402
|
+
timestamp_ms: Date.now(),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// ignore — provenance is metadata, not blocking.
|
|
407
|
+
}
|
|
408
|
+
return inserted.id;
|
|
409
|
+
}
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// expand_concept — recursive Branch with depth + breadth.
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
export const expandConceptInput = z
|
|
414
|
+
.object({
|
|
415
|
+
id: z.string().describe('The concept to expand.'),
|
|
416
|
+
depth: z
|
|
417
|
+
.number()
|
|
418
|
+
.int()
|
|
419
|
+
.min(1)
|
|
420
|
+
.max(3)
|
|
421
|
+
.describe('How many levels deep to expand (1..3).'),
|
|
422
|
+
breadth: z
|
|
423
|
+
.number()
|
|
424
|
+
.int()
|
|
425
|
+
.min(1)
|
|
426
|
+
.max(5)
|
|
427
|
+
.describe('Approximate children per node (1..5).'),
|
|
428
|
+
angle: z
|
|
429
|
+
.string()
|
|
430
|
+
.optional()
|
|
431
|
+
.describe('Optional creative-direction hint passed to the underlying free / explore operator.'),
|
|
432
|
+
})
|
|
433
|
+
.strict()
|
|
434
|
+
.refine((v) => v.depth * v.breadth <= 60, 'Safety guardrail: depth × breadth must be ≤ 60. See PLAN.md Phase 10.3.');
|
|
435
|
+
export async function expandConcept(client, args) {
|
|
436
|
+
const root = await loadAnchor(client, args.id);
|
|
437
|
+
const rootTree = { id: root.id, label: root.label, children: [] };
|
|
438
|
+
let totalCommitted = 0;
|
|
439
|
+
let totalCredits = 0;
|
|
440
|
+
// Breadth-first: at each level, expand every just-committed node by one
|
|
441
|
+
// level of EXPLORE. We commit each level immediately so the user sees
|
|
442
|
+
// partial results in the webapp as the run progresses.
|
|
443
|
+
let frontier = [
|
|
444
|
+
{ node: root, tree: rootTree },
|
|
445
|
+
];
|
|
446
|
+
for (let lvl = 0; lvl < args.depth; lvl++) {
|
|
447
|
+
const nextFrontier = [];
|
|
448
|
+
for (const cur of frontier) {
|
|
449
|
+
const runArgs = {
|
|
450
|
+
family: 'explore',
|
|
451
|
+
key: 'branch',
|
|
452
|
+
anchor_id: cur.node.id,
|
|
453
|
+
args: args.angle ? { angle: args.angle } : undefined,
|
|
454
|
+
};
|
|
455
|
+
const run = await runOperator(client, runArgs);
|
|
456
|
+
totalCredits += run.estimatedCost.credits;
|
|
457
|
+
// Take up to `breadth` candidates per node.
|
|
458
|
+
const chosen = run.candidates.slice(0, args.breadth);
|
|
459
|
+
const project = await loadProjectForNode(client, cur.node);
|
|
460
|
+
for (const cand of chosen) {
|
|
461
|
+
const newId = await commitCandidate(client, {
|
|
462
|
+
anchor: cur.node,
|
|
463
|
+
project,
|
|
464
|
+
label: cand.label,
|
|
465
|
+
description: cand.description,
|
|
466
|
+
operator: 'expand',
|
|
467
|
+
});
|
|
468
|
+
totalCommitted++;
|
|
469
|
+
const childTree = {
|
|
470
|
+
id: newId,
|
|
471
|
+
label: cand.label,
|
|
472
|
+
children: [],
|
|
473
|
+
};
|
|
474
|
+
cur.tree.children.push(childTree);
|
|
475
|
+
if (lvl + 1 < args.depth) {
|
|
476
|
+
// Re-load the freshly-inserted node so subsequent EXPLORE calls
|
|
477
|
+
// have a real NodeRow to anchor against.
|
|
478
|
+
const fresh = await loadAnchor(client, newId);
|
|
479
|
+
nextFrontier.push({ node: fresh, tree: childTree });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
frontier = nextFrontier;
|
|
484
|
+
if (frontier.length === 0)
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
rootId: root.id,
|
|
489
|
+
totalCommitted,
|
|
490
|
+
tree: rootTree,
|
|
491
|
+
estimatedCostTotal: {
|
|
492
|
+
credits: totalCredits,
|
|
493
|
+
dollars: Math.round(totalCredits) / 100,
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// OPERATOR_TOOLS export — index.ts splices this onto CLOUD_TOOLS and wires
|
|
499
|
+
// each entry's `handler(client, args)` to the lazy auth handshake.
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
export const OPERATOR_TOOLS = [
|
|
502
|
+
{
|
|
503
|
+
name: 'run_operator',
|
|
504
|
+
description: "Run an ASIT / TRIZ / Contradiction / Free / Combine / Explore operator against one concept (the anchor). Returns CANDIDATE concepts WITHOUT committing them — call `bulk_add_concepts` (or `run_operator_and_commit`) to persist. Use this when you want the agent to vet the candidates before writing. The anchor must already live in a project; the operator pulls in ancestry + validated knowledge in the same project to ground the prompt.",
|
|
505
|
+
inputSchema: runOperatorInput,
|
|
506
|
+
handler: async (client, raw) => runOperator(client, runOperatorInput.parse(raw)),
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
name: 'run_operator_and_commit',
|
|
510
|
+
description: "Same as `run_operator`, but immediately writes every top-level candidate as a child of the anchor with a partition edge and a provenance row stamped origin='mcp'. Use when the agent is confident the candidates should land directly on the canvas (e.g. inside an autonomous expand loop). Children-of-children proposed in `partitions[].children` are NOT auto-committed — request them via a separate run.",
|
|
511
|
+
inputSchema: runOperatorAndCommitInput,
|
|
512
|
+
handler: async (client, raw) => runOperatorAndCommit(client, runOperatorAndCommitInput.parse(raw)),
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
name: 'expand_concept',
|
|
516
|
+
description: 'Recursive Branch — expand a concept by `breadth` children at each of `depth` levels (depth*breadth ≤ 60). Commits each level immediately so the webapp shows partial results as the run progresses. Optionally takes an `angle` hint that biases the underlying EXPLORE operator. Best when the anchor has few or no children yet; on a dense subtree consider `run_operator(family="explore")` so the operator can read existing children and propose complementary partitions.',
|
|
517
|
+
inputSchema: expandConceptInput,
|
|
518
|
+
handler: async (client, raw) => expandConcept(client, expandConceptInput.parse(raw)),
|
|
519
|
+
},
|
|
520
|
+
];
|
|
521
|
+
// Re-exports kept narrow on purpose: index.ts only needs `makeOperatorTools`,
|
|
522
|
+
// but the inputs / shapes are exported above for unit tests when they land.
|
|
523
|
+
export const OPERATOR_FAMILIES = [
|
|
524
|
+
'asit',
|
|
525
|
+
'triz',
|
|
526
|
+
'contradiction',
|
|
527
|
+
'free',
|
|
528
|
+
'combine',
|
|
529
|
+
'explore',
|
|
530
|
+
];
|