@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,1727 @@
|
|
|
1
|
+
// Heuresis MCP — cloud-backed tool implementations.
|
|
2
|
+
//
|
|
3
|
+
// Every read/write goes through `supabase-js` against the user's
|
|
4
|
+
// authenticated session. RLS in the database is the security boundary — the
|
|
5
|
+
// MCP has exactly the user's permissions, nothing more, nothing less.
|
|
6
|
+
//
|
|
7
|
+
// Each tool's INPUT shape MIRRORS the matching webapp tool in
|
|
8
|
+
// `src/agent/tools.ts` so an agent that uses both surfaces sees a uniform
|
|
9
|
+
// contract. Outputs mirror the legacy snapshot-mode shapes from
|
|
10
|
+
// `mcp-server/src/tools.ts` where one existed, so existing prompts that
|
|
11
|
+
// reference the snapshot shape keep working.
|
|
12
|
+
//
|
|
13
|
+
// Phase 19.1 shipped 8 tools (the reads + add/update/link). Phase 19.4
|
|
14
|
+
// brings the remaining write surface to parity with src/agent/tools.ts:
|
|
15
|
+
//
|
|
16
|
+
// reads added:
|
|
17
|
+
// get_workspace_summary, list_recent_decisions, list_concepts,
|
|
18
|
+
// list_edges, find_concepts
|
|
19
|
+
// writes added:
|
|
20
|
+
// validate_concept, set_standing, archive_concept, unarchive_concept,
|
|
21
|
+
// star_concept, remove_concept, bulk_add_concepts, set_parent,
|
|
22
|
+
// rename_idea, recolor_idea, set_idea_members, create_idea,
|
|
23
|
+
// add_to_idea, delete_idea, create_project, update_project,
|
|
24
|
+
// delete_project, add_kref
|
|
25
|
+
//
|
|
26
|
+
// Skipped (in-browser-only — no server analog):
|
|
27
|
+
// * focus_on_canvas — fires a DOM CustomEvent in the webapp.
|
|
28
|
+
// * tidy_layout — UI affordance, no DB effect.
|
|
29
|
+
// * reconcile_edges — webapp-only data healing; cloud edges are already
|
|
30
|
+
// normalised.
|
|
31
|
+
// * undo — session-local Zustand undo stack; no DB-side analog.
|
|
32
|
+
// * find_in_files — needs in-browser embeddings; ships with operator
|
|
33
|
+
// parity in Phase 19.5.
|
|
34
|
+
// * add_k_node / add_c_node_disciplined / add_parking_lot_item / reflect
|
|
35
|
+
// — C-K discipline tools that are sequenced add_concept + update_concept
|
|
36
|
+
// calls with project-coverage gating; the MCP-side agent can drive that
|
|
37
|
+
// sequence using the primitives directly.
|
|
38
|
+
//
|
|
39
|
+
// Every WRITE tool stamps a public.provenance row (added in migration 0015)
|
|
40
|
+
// with origin='mcp' so the Inspector / session log shows which surface made
|
|
41
|
+
// the change. The stamp is best-effort: a failed provenance insert never
|
|
42
|
+
// fails the underlying write.
|
|
43
|
+
import { z } from 'zod';
|
|
44
|
+
import { unwrap } from './cloudClient.js';
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Shared helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
const MAX_RESULTS = 50;
|
|
49
|
+
const detailArg = z
|
|
50
|
+
.enum(['compact', 'full'])
|
|
51
|
+
.default('compact')
|
|
52
|
+
.describe("'compact' drops description/rationale to keep the payload small; 'full' includes them.");
|
|
53
|
+
function nodeView(n, detail = 'full') {
|
|
54
|
+
const base = {
|
|
55
|
+
id: n.id,
|
|
56
|
+
label: n.label,
|
|
57
|
+
status: n.status,
|
|
58
|
+
starred: n.starred,
|
|
59
|
+
parentId: n.parent_id,
|
|
60
|
+
partitionAttribute: n.partition_attribute,
|
|
61
|
+
tags: n.tags ?? [],
|
|
62
|
+
updatedAt: n.updated_at,
|
|
63
|
+
standing: n.standing,
|
|
64
|
+
};
|
|
65
|
+
if (detail === 'compact')
|
|
66
|
+
return base;
|
|
67
|
+
return { ...base, description: n.description, rationale: n.rationale };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the workspace the MCP session should operate against. v1 ships
|
|
71
|
+
* with single-workspace selection (spec §10.2): use the user's first
|
|
72
|
+
* membership in alphabetical order by workspace name. A future
|
|
73
|
+
* `npx @heuresis/mcp workspace <id>` command will swap this.
|
|
74
|
+
*/
|
|
75
|
+
async function resolveWorkspaceId(client) {
|
|
76
|
+
const res = await client
|
|
77
|
+
.from('workspaces')
|
|
78
|
+
.select('id, name')
|
|
79
|
+
.order('name', { ascending: true })
|
|
80
|
+
.limit(1);
|
|
81
|
+
const rows = unwrap(res);
|
|
82
|
+
if (rows.length === 0) {
|
|
83
|
+
throw new Error('No workspaces visible to this account. Open the Heuresis webapp first to create or be invited to a workspace.');
|
|
84
|
+
}
|
|
85
|
+
return rows[0].id;
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// list_projects
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
export const listProjectsInput = z.object({}).strict();
|
|
91
|
+
export async function listProjects(client) {
|
|
92
|
+
const wsId = await resolveWorkspaceId(client);
|
|
93
|
+
const projects = unwrap(await client
|
|
94
|
+
.from('projects')
|
|
95
|
+
.select('*')
|
|
96
|
+
.eq('workspace_id', wsId)
|
|
97
|
+
.order('updated_at', { ascending: false }));
|
|
98
|
+
const projectNodes = unwrap(await client
|
|
99
|
+
.from('project_nodes')
|
|
100
|
+
.select('project_id, node_id')
|
|
101
|
+
.in('project_id', projects.map((p) => p.id)));
|
|
102
|
+
const countByProject = new Map();
|
|
103
|
+
for (const pn of projectNodes) {
|
|
104
|
+
countByProject.set(pn.project_id, (countByProject.get(pn.project_id) ?? 0) + 1);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
workspaceId: wsId,
|
|
108
|
+
projects: projects.map((p) => ({
|
|
109
|
+
id: p.id,
|
|
110
|
+
name: p.name,
|
|
111
|
+
brief: p.brief,
|
|
112
|
+
direction: p.direction,
|
|
113
|
+
lifecycle: p.lifecycle,
|
|
114
|
+
nodeCount: countByProject.get(p.id) ?? 0,
|
|
115
|
+
rootNodeId: p.root_node_id,
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// get_project_graph
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
export const getProjectGraphInput = z
|
|
123
|
+
.object({
|
|
124
|
+
projectId: z.string(),
|
|
125
|
+
includeArchived: z.boolean().default(false),
|
|
126
|
+
detail: detailArg,
|
|
127
|
+
})
|
|
128
|
+
.strict();
|
|
129
|
+
export async function getProjectGraph(client, args) {
|
|
130
|
+
const projRes = await client
|
|
131
|
+
.from('projects')
|
|
132
|
+
.select('*')
|
|
133
|
+
.eq('id', args.projectId)
|
|
134
|
+
.maybeSingle();
|
|
135
|
+
if (projRes.error)
|
|
136
|
+
throw new Error(projRes.error.message);
|
|
137
|
+
const project = projRes.data;
|
|
138
|
+
if (!project)
|
|
139
|
+
return { error: `No project with id ${args.projectId}` };
|
|
140
|
+
const memberRows = unwrap(await client
|
|
141
|
+
.from('project_nodes')
|
|
142
|
+
.select('node_id')
|
|
143
|
+
.eq('project_id', project.id));
|
|
144
|
+
const memberIds = memberRows.map((r) => r.node_id);
|
|
145
|
+
if (memberIds.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
project: shapeProject(project),
|
|
148
|
+
detail: args.detail,
|
|
149
|
+
nodeCount: 0,
|
|
150
|
+
edgeCount: 0,
|
|
151
|
+
nodes: [],
|
|
152
|
+
edges: [],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const nodesRes = await client.from('nodes').select('*').in('id', memberIds);
|
|
156
|
+
const nodes = unwrap(nodesRes).filter((n) => args.includeArchived ? true : n.status !== 'archived');
|
|
157
|
+
const nodeIdSet = new Set(nodes.map((n) => n.id));
|
|
158
|
+
// Pull every edge whose endpoints are in this project. Two `in()` filters
|
|
159
|
+
// in PostgREST require an `or()`.
|
|
160
|
+
const edgesRes = await client
|
|
161
|
+
.from('edges')
|
|
162
|
+
.select('id, from_id, to_id, kind')
|
|
163
|
+
.in('from_id', memberIds);
|
|
164
|
+
const edges = unwrap(edgesRes)
|
|
165
|
+
.filter((e) => nodeIdSet.has(e.from_id) && nodeIdSet.has(e.to_id))
|
|
166
|
+
.map((e) => ({ from: e.from_id, to: e.to_id, kind: e.kind }));
|
|
167
|
+
return {
|
|
168
|
+
project: shapeProject(project),
|
|
169
|
+
detail: args.detail,
|
|
170
|
+
nodeCount: nodes.length,
|
|
171
|
+
edgeCount: edges.length,
|
|
172
|
+
nodes: nodes.map((n) => nodeView(n, args.detail)),
|
|
173
|
+
edges,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function shapeProject(p) {
|
|
177
|
+
return {
|
|
178
|
+
id: p.id,
|
|
179
|
+
name: p.name,
|
|
180
|
+
brief: p.brief,
|
|
181
|
+
direction: p.direction,
|
|
182
|
+
lifecycle: p.lifecycle,
|
|
183
|
+
rootNodeId: p.root_node_id,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// get_subtree
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
export const getSubtreeInput = z
|
|
190
|
+
.object({
|
|
191
|
+
rootId: z.string(),
|
|
192
|
+
depth: z.number().int().min(0).max(6).default(3),
|
|
193
|
+
detail: detailArg,
|
|
194
|
+
})
|
|
195
|
+
.strict();
|
|
196
|
+
export async function getSubtree(client, args) {
|
|
197
|
+
const rootRes = await client.from('nodes').select('*').eq('id', args.rootId).maybeSingle();
|
|
198
|
+
if (rootRes.error)
|
|
199
|
+
throw new Error(rootRes.error.message);
|
|
200
|
+
const root = rootRes.data;
|
|
201
|
+
if (!root)
|
|
202
|
+
return { error: `No concept with id ${args.rootId}` };
|
|
203
|
+
const wsId = root.workspace_id;
|
|
204
|
+
// Pull ALL workspace nodes + partition edges in one shot, then walk in
|
|
205
|
+
// memory. Cheaper than depth round-trips for any realistic workspace size,
|
|
206
|
+
// and still covered by RLS.
|
|
207
|
+
const allNodes = unwrap(await client.from('nodes').select('*').eq('workspace_id', wsId));
|
|
208
|
+
const partitionEdges = unwrap(await client
|
|
209
|
+
.from('edges')
|
|
210
|
+
.select('from_id, to_id, kind')
|
|
211
|
+
.eq('workspace_id', wsId)
|
|
212
|
+
.eq('kind', 'partition'));
|
|
213
|
+
const nodeById = new Map(allNodes.map((n) => [n.id, n]));
|
|
214
|
+
const childrenByParent = new Map();
|
|
215
|
+
for (const n of allNodes) {
|
|
216
|
+
if (n.parent_id) {
|
|
217
|
+
const s = childrenByParent.get(n.parent_id) ?? new Set();
|
|
218
|
+
s.add(n.id);
|
|
219
|
+
childrenByParent.set(n.parent_id, s);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const e of partitionEdges) {
|
|
223
|
+
const s = childrenByParent.get(e.from_id) ?? new Set();
|
|
224
|
+
s.add(e.to_id);
|
|
225
|
+
childrenByParent.set(e.from_id, s);
|
|
226
|
+
}
|
|
227
|
+
const out = [];
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
let frontier = [args.rootId];
|
|
230
|
+
for (let d = 0; d <= args.depth; d++) {
|
|
231
|
+
const next = [];
|
|
232
|
+
for (const cur of frontier) {
|
|
233
|
+
if (seen.has(cur))
|
|
234
|
+
continue;
|
|
235
|
+
seen.add(cur);
|
|
236
|
+
const node = nodeById.get(cur);
|
|
237
|
+
if (!node)
|
|
238
|
+
continue;
|
|
239
|
+
out.push(node);
|
|
240
|
+
if (d < args.depth) {
|
|
241
|
+
const kids = childrenByParent.get(cur);
|
|
242
|
+
if (kids)
|
|
243
|
+
for (const k of kids)
|
|
244
|
+
if (!seen.has(k))
|
|
245
|
+
next.push(k);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
frontier = next;
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
rootId: args.rootId,
|
|
252
|
+
depth: args.depth,
|
|
253
|
+
detail: args.detail,
|
|
254
|
+
nodeCount: out.length,
|
|
255
|
+
nodes: out.map((n) => nodeView(n, args.detail)),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// get_concept
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
export const getConceptInput = z
|
|
262
|
+
.object({
|
|
263
|
+
id: z.string(),
|
|
264
|
+
includeAncestry: z.boolean().default(true),
|
|
265
|
+
includeChildren: z.boolean().default(true),
|
|
266
|
+
includeIdeaMemberships: z.boolean().default(true),
|
|
267
|
+
})
|
|
268
|
+
.strict();
|
|
269
|
+
export async function getConcept(client, args) {
|
|
270
|
+
const nodeRes = await client.from('nodes').select('*').eq('id', args.id).maybeSingle();
|
|
271
|
+
if (nodeRes.error)
|
|
272
|
+
throw new Error(nodeRes.error.message);
|
|
273
|
+
const node = nodeRes.data;
|
|
274
|
+
if (!node)
|
|
275
|
+
return { error: `No concept with id ${args.id}` };
|
|
276
|
+
const out = { node: nodeView(node) };
|
|
277
|
+
if (args.includeAncestry) {
|
|
278
|
+
const chain = [];
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
let cur = node.parent_id;
|
|
281
|
+
while (cur && !seen.has(cur)) {
|
|
282
|
+
seen.add(cur);
|
|
283
|
+
const p = unwrap(await client.from('nodes').select('id, label, parent_id').eq('id', cur).maybeSingle());
|
|
284
|
+
if (!p)
|
|
285
|
+
break;
|
|
286
|
+
chain.unshift({ id: p.id, label: p.label });
|
|
287
|
+
cur = p.parent_id;
|
|
288
|
+
}
|
|
289
|
+
out.ancestry = chain;
|
|
290
|
+
}
|
|
291
|
+
if (args.includeChildren) {
|
|
292
|
+
const kids = unwrap(await client
|
|
293
|
+
.from('nodes')
|
|
294
|
+
.select('id, label, status')
|
|
295
|
+
.eq('parent_id', node.id));
|
|
296
|
+
out.children = kids;
|
|
297
|
+
}
|
|
298
|
+
if (args.includeIdeaMemberships) {
|
|
299
|
+
// Fetch idea_nodes for this node, then idea rows separately. Joining
|
|
300
|
+
// through PostgREST's `ideas(...)` shorthand returns the related row(s)
|
|
301
|
+
// as an array which complicates typing here — two queries are cheap.
|
|
302
|
+
const ideaNodeRows = unwrap(await client.from('idea_nodes').select('idea_id').eq('node_id', node.id));
|
|
303
|
+
const ideaIds = ideaNodeRows.map((r) => r.idea_id);
|
|
304
|
+
if (ideaIds.length === 0) {
|
|
305
|
+
out.ideaMemberships = [];
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
const ideas = unwrap(await client.from('ideas').select('id, name, color').in('id', ideaIds));
|
|
309
|
+
out.ideaMemberships = ideas.map((i) => ({ id: i.id, name: i.name, color: i.color }));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// search_concepts (text-only for 19.1; semantic search is a 19.4 enhancement)
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
export const searchConceptsInput = z
|
|
318
|
+
.object({
|
|
319
|
+
query: z
|
|
320
|
+
.string()
|
|
321
|
+
.describe('Substring matched against label, description, tags, partitionAttribute.'),
|
|
322
|
+
limit: z.number().int().min(1).max(MAX_RESULTS).default(20),
|
|
323
|
+
projectId: z
|
|
324
|
+
.string()
|
|
325
|
+
.optional()
|
|
326
|
+
.describe('Restrict results to nodes belonging to this project.'),
|
|
327
|
+
status: z.enum(['open', 'validated', 'archived']).optional(),
|
|
328
|
+
detail: detailArg,
|
|
329
|
+
})
|
|
330
|
+
.strict();
|
|
331
|
+
export async function searchConcepts(client, args) {
|
|
332
|
+
const q = args.query.trim();
|
|
333
|
+
const wsId = await resolveWorkspaceId(client);
|
|
334
|
+
// Restrict to a project if requested.
|
|
335
|
+
let memberIds = null;
|
|
336
|
+
if (args.projectId) {
|
|
337
|
+
const rows = unwrap(await client
|
|
338
|
+
.from('project_nodes')
|
|
339
|
+
.select('node_id')
|
|
340
|
+
.eq('project_id', args.projectId));
|
|
341
|
+
memberIds = rows.map((r) => r.node_id);
|
|
342
|
+
if (memberIds.length === 0) {
|
|
343
|
+
return { query: q, total: 0, detail: args.detail, results: [] };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
let qb = client
|
|
347
|
+
.from('nodes')
|
|
348
|
+
.select('*')
|
|
349
|
+
.eq('workspace_id', wsId)
|
|
350
|
+
.order('updated_at', { ascending: false })
|
|
351
|
+
.limit(args.limit);
|
|
352
|
+
if (args.status)
|
|
353
|
+
qb = qb.eq('status', args.status);
|
|
354
|
+
if (memberIds)
|
|
355
|
+
qb = qb.in('id', memberIds);
|
|
356
|
+
if (q.length > 0) {
|
|
357
|
+
// Server-side ilike over label/description/partition_attribute/rationale.
|
|
358
|
+
// tags is text[] — PostgREST `cs.{value}` would require exact match, so
|
|
359
|
+
// we filter tags client-side after fetching a slightly larger page.
|
|
360
|
+
const escaped = q.replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
361
|
+
const pattern = `%${escaped}%`;
|
|
362
|
+
qb = qb.or([
|
|
363
|
+
`label.ilike.${pattern}`,
|
|
364
|
+
`description.ilike.${pattern}`,
|
|
365
|
+
`partition_attribute.ilike.${pattern}`,
|
|
366
|
+
`rationale.ilike.${pattern}`,
|
|
367
|
+
].join(','));
|
|
368
|
+
}
|
|
369
|
+
const rows = unwrap(await qb);
|
|
370
|
+
const lower = q.toLowerCase();
|
|
371
|
+
// tag-match top-up: when the query string appears verbatim in a tag we
|
|
372
|
+
// want to surface it even if it wasn't in any other column.
|
|
373
|
+
let extra = [];
|
|
374
|
+
if (q.length > 0 && rows.length < args.limit) {
|
|
375
|
+
let tagQb = client
|
|
376
|
+
.from('nodes')
|
|
377
|
+
.select('*')
|
|
378
|
+
.eq('workspace_id', wsId)
|
|
379
|
+
.contains('tags', [q])
|
|
380
|
+
.limit(args.limit);
|
|
381
|
+
if (memberIds)
|
|
382
|
+
tagQb = tagQb.in('id', memberIds);
|
|
383
|
+
extra = unwrap(await tagQb);
|
|
384
|
+
}
|
|
385
|
+
const dedup = new Map();
|
|
386
|
+
for (const n of rows)
|
|
387
|
+
dedup.set(n.id, n);
|
|
388
|
+
for (const n of extra)
|
|
389
|
+
dedup.set(n.id, n);
|
|
390
|
+
const hits = Array.from(dedup.values())
|
|
391
|
+
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
392
|
+
.slice(0, args.limit)
|
|
393
|
+
.map((n) => nodeView(n, args.detail));
|
|
394
|
+
return { query: q || '', total: hits.length, detail: args.detail, results: hits, note: q ? undefined : 'Empty query — returned most-recently-updated concepts.' };
|
|
395
|
+
}
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// add_concept (write)
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Mirrors `add_concept` in src/agent/tools.ts. If `parentId` is given, the
|
|
400
|
+
// new node becomes a child of that node AND a partition edge is created so
|
|
401
|
+
// the canvas renders the line. The node is also added to the parent's
|
|
402
|
+
// project via `project_nodes`.
|
|
403
|
+
export const addConceptInput = z
|
|
404
|
+
.object({
|
|
405
|
+
label: z.string().min(1).max(140),
|
|
406
|
+
description: z.string().max(2000).optional(),
|
|
407
|
+
parentId: z.string().nullable().optional(),
|
|
408
|
+
projectId: z
|
|
409
|
+
.string()
|
|
410
|
+
.optional()
|
|
411
|
+
.describe('Required when parentId is null/omitted AND the workspace has more than one project. Otherwise inferred.'),
|
|
412
|
+
tags: z.array(z.string()).optional(),
|
|
413
|
+
})
|
|
414
|
+
.strict();
|
|
415
|
+
export async function addConcept(client, args) {
|
|
416
|
+
const wsId = await resolveWorkspaceId(client);
|
|
417
|
+
// Figure out parent + project.
|
|
418
|
+
let parentNode = null;
|
|
419
|
+
if (typeof args.parentId === 'string') {
|
|
420
|
+
parentNode = unwrap(await client.from('nodes').select('*').eq('id', args.parentId).maybeSingle());
|
|
421
|
+
if (!parentNode)
|
|
422
|
+
return { error: `Parent ${args.parentId} not found.` };
|
|
423
|
+
}
|
|
424
|
+
let projectId = args.projectId ?? null;
|
|
425
|
+
if (!projectId && parentNode) {
|
|
426
|
+
// Inherit the parent's project (the first one if it sits in many).
|
|
427
|
+
const rows = unwrap(await client
|
|
428
|
+
.from('project_nodes')
|
|
429
|
+
.select('project_id')
|
|
430
|
+
.eq('node_id', parentNode.id)
|
|
431
|
+
.limit(1));
|
|
432
|
+
if (rows.length > 0)
|
|
433
|
+
projectId = rows[0].project_id;
|
|
434
|
+
}
|
|
435
|
+
if (!projectId) {
|
|
436
|
+
// Fall back to the workspace's only project. If there's >1 and the
|
|
437
|
+
// caller didn't pass projectId we refuse rather than guess.
|
|
438
|
+
const rows = unwrap(await client.from('projects').select('id').eq('workspace_id', wsId));
|
|
439
|
+
if (rows.length === 0) {
|
|
440
|
+
return {
|
|
441
|
+
error: 'No project in this workspace. Create one in the webapp first (or via create_project once that tool ships in 19.4).',
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (rows.length > 1) {
|
|
445
|
+
return {
|
|
446
|
+
error: 'Workspace has multiple projects — pass `projectId` to disambiguate (or set `parentId` so the project is inherited).',
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
projectId = rows[0].id;
|
|
450
|
+
}
|
|
451
|
+
// Insert the node.
|
|
452
|
+
const insertNode = unwrap(await client
|
|
453
|
+
.from('nodes')
|
|
454
|
+
.insert({
|
|
455
|
+
workspace_id: wsId,
|
|
456
|
+
parent_id: parentNode?.id ?? null,
|
|
457
|
+
label: args.label,
|
|
458
|
+
description: args.description ?? '',
|
|
459
|
+
tags: args.tags ?? [],
|
|
460
|
+
project_id: projectId,
|
|
461
|
+
})
|
|
462
|
+
.select('*')
|
|
463
|
+
.single());
|
|
464
|
+
// Link to the project.
|
|
465
|
+
await client
|
|
466
|
+
.from('project_nodes')
|
|
467
|
+
.insert({ project_id: projectId, node_id: insertNode.id })
|
|
468
|
+
.select('id')
|
|
469
|
+
.maybeSingle();
|
|
470
|
+
// Partition edge if parented.
|
|
471
|
+
if (parentNode) {
|
|
472
|
+
await client
|
|
473
|
+
.from('edges')
|
|
474
|
+
.insert({
|
|
475
|
+
workspace_id: wsId,
|
|
476
|
+
from_id: parentNode.id,
|
|
477
|
+
to_id: insertNode.id,
|
|
478
|
+
kind: 'partition',
|
|
479
|
+
})
|
|
480
|
+
.select('id')
|
|
481
|
+
.maybeSingle();
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
id: insertNode.id,
|
|
485
|
+
label: insertNode.label,
|
|
486
|
+
parentId: insertNode.parent_id,
|
|
487
|
+
projectId,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// update_concept (write)
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
export const updateConceptInput = z
|
|
494
|
+
.object({
|
|
495
|
+
id: z.string().min(1),
|
|
496
|
+
label: z.string().optional(),
|
|
497
|
+
description: z.string().optional(),
|
|
498
|
+
tags: z.array(z.string()).optional(),
|
|
499
|
+
partitionAttribute: z.string().optional(),
|
|
500
|
+
rationale: z.string().optional(),
|
|
501
|
+
status: z.enum(['open', 'validated', 'archived']).optional(),
|
|
502
|
+
})
|
|
503
|
+
.strict();
|
|
504
|
+
export async function updateConcept(client, args) {
|
|
505
|
+
const patch = {};
|
|
506
|
+
if (args.label !== undefined)
|
|
507
|
+
patch.label = args.label;
|
|
508
|
+
if (args.description !== undefined)
|
|
509
|
+
patch.description = args.description;
|
|
510
|
+
if (args.tags !== undefined)
|
|
511
|
+
patch.tags = args.tags;
|
|
512
|
+
if (args.partitionAttribute !== undefined)
|
|
513
|
+
patch.partition_attribute = args.partitionAttribute;
|
|
514
|
+
if (args.rationale !== undefined)
|
|
515
|
+
patch.rationale = args.rationale;
|
|
516
|
+
if (args.status !== undefined)
|
|
517
|
+
patch.status = args.status;
|
|
518
|
+
if (Object.keys(patch).length === 0) {
|
|
519
|
+
return { error: 'No fields to update.' };
|
|
520
|
+
}
|
|
521
|
+
const row = unwrap(await client
|
|
522
|
+
.from('nodes')
|
|
523
|
+
.update(patch)
|
|
524
|
+
.eq('id', args.id)
|
|
525
|
+
.select('id, label, status')
|
|
526
|
+
.maybeSingle());
|
|
527
|
+
if (!row)
|
|
528
|
+
return { error: `No concept with id ${args.id}` };
|
|
529
|
+
return { id: row.id, label: row.label, status: row.status };
|
|
530
|
+
}
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// link_concepts (write — k-ref / derived-from / semantic-adjacency only)
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// We DON'T expose `partition` here — partition edges are an artifact of
|
|
535
|
+
// parent_id and are managed via add_concept / set_parent. Matches the
|
|
536
|
+
// webapp tool's contract.
|
|
537
|
+
export const linkConceptsInput = z
|
|
538
|
+
.object({
|
|
539
|
+
fromId: z.string().min(1),
|
|
540
|
+
toId: z.string().min(1),
|
|
541
|
+
kind: z.enum(['k-ref', 'derived-from', 'semantic-adjacency']),
|
|
542
|
+
})
|
|
543
|
+
.strict();
|
|
544
|
+
export async function linkConcepts(client, args) {
|
|
545
|
+
if (args.fromId === args.toId) {
|
|
546
|
+
return { error: 'Self-loop edges are not allowed.' };
|
|
547
|
+
}
|
|
548
|
+
const from = unwrap(await client
|
|
549
|
+
.from('nodes')
|
|
550
|
+
.select('id, workspace_id')
|
|
551
|
+
.eq('id', args.fromId)
|
|
552
|
+
.maybeSingle());
|
|
553
|
+
if (!from)
|
|
554
|
+
return { error: `No concept with id ${args.fromId}` };
|
|
555
|
+
const to = unwrap(await client
|
|
556
|
+
.from('nodes')
|
|
557
|
+
.select('id, workspace_id')
|
|
558
|
+
.eq('id', args.toId)
|
|
559
|
+
.maybeSingle());
|
|
560
|
+
if (!to)
|
|
561
|
+
return { error: `No concept with id ${args.toId}` };
|
|
562
|
+
if (from.workspace_id !== to.workspace_id) {
|
|
563
|
+
return {
|
|
564
|
+
error: 'Cannot link concepts from different workspaces.',
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
// Reject duplicate edges of the same kind on the same pair.
|
|
568
|
+
const dup = unwrap(await client
|
|
569
|
+
.from('edges')
|
|
570
|
+
.select('id')
|
|
571
|
+
.eq('from_id', from.id)
|
|
572
|
+
.eq('to_id', to.id)
|
|
573
|
+
.eq('kind', args.kind)
|
|
574
|
+
.maybeSingle());
|
|
575
|
+
if (dup) {
|
|
576
|
+
return { id: dup.id, fromId: from.id, toId: to.id, kind: args.kind, duplicate: true };
|
|
577
|
+
}
|
|
578
|
+
const row = unwrap(await client
|
|
579
|
+
.from('edges')
|
|
580
|
+
.insert({
|
|
581
|
+
workspace_id: from.workspace_id,
|
|
582
|
+
from_id: from.id,
|
|
583
|
+
to_id: to.id,
|
|
584
|
+
kind: args.kind,
|
|
585
|
+
})
|
|
586
|
+
.select('id, from_id, to_id, kind')
|
|
587
|
+
.single());
|
|
588
|
+
return { id: row.id, fromId: row.from_id, toId: row.to_id, kind: row.kind };
|
|
589
|
+
}
|
|
590
|
+
async function stampProvenance(client, args) {
|
|
591
|
+
try {
|
|
592
|
+
const row = {
|
|
593
|
+
workspace_id: args.workspaceId,
|
|
594
|
+
node_id: args.nodeId,
|
|
595
|
+
origin: args.origin ?? 'mcp',
|
|
596
|
+
operator_key: args.operatorKey ?? null,
|
|
597
|
+
source_refs: [args.sourceRef],
|
|
598
|
+
llm_json: args.llmJson ?? null,
|
|
599
|
+
created_by: 'agent',
|
|
600
|
+
analysis_id: args.analysisId ?? null,
|
|
601
|
+
analysis_tool: args.analysisTool ?? null,
|
|
602
|
+
timestamp_ms: Date.now(),
|
|
603
|
+
};
|
|
604
|
+
const res = await client.from('provenance').insert(row);
|
|
605
|
+
if (res.error) {
|
|
606
|
+
console.error(`[heuresis-mcp] provenance insert failed (best-effort): ${res.error.message}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
console.error(`[heuresis-mcp] provenance insert threw (best-effort): ${err instanceof Error ? err.message : String(err)}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// ===========================================================================
|
|
614
|
+
// READS — additions (Phase 19.4)
|
|
615
|
+
// ===========================================================================
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// get_workspace_summary
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Same shape the legacy snapshot mode produced so existing agent prompts
|
|
620
|
+
// keep working. Source of truth is the cloud workspace, not a snapshot file.
|
|
621
|
+
export const getWorkspaceSummaryInput = z.object({}).strict();
|
|
622
|
+
export async function getWorkspaceSummary(client) {
|
|
623
|
+
const wsId = await resolveWorkspaceId(client);
|
|
624
|
+
const ws = unwrap(await client
|
|
625
|
+
.from('workspaces')
|
|
626
|
+
.select('id, name')
|
|
627
|
+
.eq('id', wsId)
|
|
628
|
+
.maybeSingle());
|
|
629
|
+
const [projectRows, ideaRows, nodeCountRes, edgeCountRes] = await Promise.all([
|
|
630
|
+
client.from('projects').select('*').eq('workspace_id', wsId),
|
|
631
|
+
client.from('ideas').select('*').eq('workspace_id', wsId),
|
|
632
|
+
client
|
|
633
|
+
.from('nodes')
|
|
634
|
+
.select('id', { count: 'exact', head: true })
|
|
635
|
+
.eq('workspace_id', wsId),
|
|
636
|
+
client
|
|
637
|
+
.from('edges')
|
|
638
|
+
.select('id', { count: 'exact', head: true })
|
|
639
|
+
.eq('workspace_id', wsId),
|
|
640
|
+
]);
|
|
641
|
+
const projects = unwrap(projectRows);
|
|
642
|
+
const ideas = unwrap(ideaRows);
|
|
643
|
+
if (nodeCountRes.error)
|
|
644
|
+
throw new Error(nodeCountRes.error.message);
|
|
645
|
+
if (edgeCountRes.error)
|
|
646
|
+
throw new Error(edgeCountRes.error.message);
|
|
647
|
+
// Per-project membership counts.
|
|
648
|
+
const pn = unwrap(await client
|
|
649
|
+
.from('project_nodes')
|
|
650
|
+
.select('project_id, node_id')
|
|
651
|
+
.in('project_id', projects.map((p) => p.id)));
|
|
652
|
+
const projCount = new Map();
|
|
653
|
+
for (const r of pn)
|
|
654
|
+
projCount.set(r.project_id, (projCount.get(r.project_id) ?? 0) + 1);
|
|
655
|
+
// Per-idea membership counts.
|
|
656
|
+
const inJ = unwrap(await client
|
|
657
|
+
.from('idea_nodes')
|
|
658
|
+
.select('idea_id, node_id')
|
|
659
|
+
.in('idea_id', ideas.map((i) => i.id)));
|
|
660
|
+
const ideaCount = new Map();
|
|
661
|
+
for (const r of inJ)
|
|
662
|
+
ideaCount.set(r.idea_id, (ideaCount.get(r.idea_id) ?? 0) + 1);
|
|
663
|
+
return {
|
|
664
|
+
workspaceId: wsId,
|
|
665
|
+
workspaces: ws ? [{ id: ws.id, name: ws.name }] : [],
|
|
666
|
+
counts: {
|
|
667
|
+
nodes: nodeCountRes.count ?? 0,
|
|
668
|
+
edges: edgeCountRes.count ?? 0,
|
|
669
|
+
projects: projects.length,
|
|
670
|
+
ideas: ideas.length,
|
|
671
|
+
},
|
|
672
|
+
projects: projects.map((p) => ({
|
|
673
|
+
id: p.id,
|
|
674
|
+
name: p.name,
|
|
675
|
+
brief: p.brief,
|
|
676
|
+
direction: p.direction,
|
|
677
|
+
lifecycle: p.lifecycle,
|
|
678
|
+
nodeCount: projCount.get(p.id) ?? 0,
|
|
679
|
+
rootNodeId: p.root_node_id,
|
|
680
|
+
})),
|
|
681
|
+
ideas: ideas.map((i) => ({
|
|
682
|
+
id: i.id,
|
|
683
|
+
name: i.name,
|
|
684
|
+
color: i.color,
|
|
685
|
+
nodeCount: ideaCount.get(i.id) ?? 0,
|
|
686
|
+
})),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// list_recent_decisions
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
// "Decisions" = nodes the user has explicitly resolved recently (validated,
|
|
693
|
+
// starred, or archived). Same shape as the legacy snapshot tool.
|
|
694
|
+
export const listRecentDecisionsInput = z
|
|
695
|
+
.object({
|
|
696
|
+
sinceMs: z
|
|
697
|
+
.number()
|
|
698
|
+
.int()
|
|
699
|
+
.optional()
|
|
700
|
+
.describe('Unix-ms cutoff; default = last 7 days.'),
|
|
701
|
+
limit: z.number().int().min(1).max(MAX_RESULTS).default(20),
|
|
702
|
+
})
|
|
703
|
+
.strict();
|
|
704
|
+
export async function listRecentDecisions(client, args) {
|
|
705
|
+
const wsId = await resolveWorkspaceId(client);
|
|
706
|
+
const cutoff = args.sinceMs ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
707
|
+
const cutoffIso = new Date(cutoff).toISOString();
|
|
708
|
+
// Fetch the most-recently-updated nodes that match a decision shape.
|
|
709
|
+
const rows = unwrap(await client
|
|
710
|
+
.from('nodes')
|
|
711
|
+
.select('id, label, status, starred, updated_at')
|
|
712
|
+
.eq('workspace_id', wsId)
|
|
713
|
+
.gte('updated_at', cutoffIso)
|
|
714
|
+
.order('updated_at', { ascending: false })
|
|
715
|
+
.limit(args.limit * 4));
|
|
716
|
+
const decisions = rows
|
|
717
|
+
.filter((n) => n.starred || n.status === 'validated' || n.status === 'archived')
|
|
718
|
+
.slice(0, args.limit)
|
|
719
|
+
.map((n) => ({
|
|
720
|
+
id: n.id,
|
|
721
|
+
label: n.label,
|
|
722
|
+
status: n.status,
|
|
723
|
+
starred: n.starred,
|
|
724
|
+
updatedAt: n.updated_at,
|
|
725
|
+
decision: n.status === 'validated'
|
|
726
|
+
? 'validated'
|
|
727
|
+
: n.status === 'archived'
|
|
728
|
+
? 'archived'
|
|
729
|
+
: n.starred
|
|
730
|
+
? 'starred'
|
|
731
|
+
: 'open',
|
|
732
|
+
}));
|
|
733
|
+
return {
|
|
734
|
+
sinceMs: cutoff,
|
|
735
|
+
since: cutoffIso,
|
|
736
|
+
count: decisions.length,
|
|
737
|
+
decisions,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
// list_concepts
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
// Mirrors webapp `list_concepts`. `scope`='project' uses projectId arg (the
|
|
744
|
+
// MCP has no notion of a "current project" the way the webapp does), so the
|
|
745
|
+
// MCP arg adds projectId where the webapp implicitly reads currentProjectId.
|
|
746
|
+
export const listConceptsInput = z
|
|
747
|
+
.object({
|
|
748
|
+
scope: z.enum(['project', 'workspace']).default('workspace'),
|
|
749
|
+
projectId: z
|
|
750
|
+
.string()
|
|
751
|
+
.optional()
|
|
752
|
+
.describe('Required when scope=project.'),
|
|
753
|
+
includeArchived: z.boolean().default(false),
|
|
754
|
+
detail: detailArg,
|
|
755
|
+
limit: z.number().int().min(1).max(500).default(500),
|
|
756
|
+
})
|
|
757
|
+
.strict();
|
|
758
|
+
export async function listConcepts(client, args) {
|
|
759
|
+
const wsId = await resolveWorkspaceId(client);
|
|
760
|
+
let memberIds = null;
|
|
761
|
+
let projectName = null;
|
|
762
|
+
if (args.scope === 'project') {
|
|
763
|
+
if (!args.projectId) {
|
|
764
|
+
return { error: 'scope=project requires projectId.' };
|
|
765
|
+
}
|
|
766
|
+
const proj = unwrap(await client
|
|
767
|
+
.from('projects')
|
|
768
|
+
.select('id, name')
|
|
769
|
+
.eq('id', args.projectId)
|
|
770
|
+
.maybeSingle());
|
|
771
|
+
if (!proj)
|
|
772
|
+
return { error: `No project with id ${args.projectId}` };
|
|
773
|
+
projectName = proj.name;
|
|
774
|
+
const rows = unwrap(await client
|
|
775
|
+
.from('project_nodes')
|
|
776
|
+
.select('node_id')
|
|
777
|
+
.eq('project_id', args.projectId));
|
|
778
|
+
memberIds = rows.map((r) => r.node_id);
|
|
779
|
+
if (memberIds.length === 0) {
|
|
780
|
+
return {
|
|
781
|
+
scope: args.scope,
|
|
782
|
+
projectName,
|
|
783
|
+
total: 0,
|
|
784
|
+
truncated: false,
|
|
785
|
+
concepts: [],
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
let qb = client
|
|
790
|
+
.from('nodes')
|
|
791
|
+
.select('*')
|
|
792
|
+
.eq('workspace_id', wsId)
|
|
793
|
+
.order('updated_at', { ascending: false })
|
|
794
|
+
.limit(args.limit);
|
|
795
|
+
if (memberIds)
|
|
796
|
+
qb = qb.in('id', memberIds);
|
|
797
|
+
if (!args.includeArchived)
|
|
798
|
+
qb = qb.neq('status', 'archived');
|
|
799
|
+
const rows = unwrap(await qb);
|
|
800
|
+
return {
|
|
801
|
+
scope: args.scope,
|
|
802
|
+
projectName,
|
|
803
|
+
total: rows.length,
|
|
804
|
+
truncated: rows.length === args.limit,
|
|
805
|
+
concepts: rows.map((n) => nodeView(n, args.detail)),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
// list_edges
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// Mirrors webapp `list_edges`. Restrict to a project when projectId is given.
|
|
812
|
+
export const listEdgesInput = z
|
|
813
|
+
.object({
|
|
814
|
+
projectId: z.string().optional(),
|
|
815
|
+
kind: z
|
|
816
|
+
.enum(['partition', 'k-ref', 'semantic-adjacency', 'derived-from', 'imported-from'])
|
|
817
|
+
.optional(),
|
|
818
|
+
})
|
|
819
|
+
.strict();
|
|
820
|
+
export async function listEdges(client, args) {
|
|
821
|
+
const wsId = await resolveWorkspaceId(client);
|
|
822
|
+
let memberIds = null;
|
|
823
|
+
if (args.projectId) {
|
|
824
|
+
const rows = unwrap(await client
|
|
825
|
+
.from('project_nodes')
|
|
826
|
+
.select('node_id')
|
|
827
|
+
.eq('project_id', args.projectId));
|
|
828
|
+
memberIds = new Set(rows.map((r) => r.node_id));
|
|
829
|
+
if (memberIds.size === 0) {
|
|
830
|
+
return { total: 0, edges: [] };
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
let qb = client
|
|
834
|
+
.from('edges')
|
|
835
|
+
.select('id, from_id, to_id, kind')
|
|
836
|
+
.eq('workspace_id', wsId);
|
|
837
|
+
if (args.kind)
|
|
838
|
+
qb = qb.eq('kind', args.kind);
|
|
839
|
+
const rows = unwrap(await qb);
|
|
840
|
+
const filtered = memberIds
|
|
841
|
+
? rows.filter((e) => memberIds.has(e.from_id) && memberIds.has(e.to_id))
|
|
842
|
+
: rows;
|
|
843
|
+
return {
|
|
844
|
+
total: filtered.length,
|
|
845
|
+
edges: filtered.map((e) => ({
|
|
846
|
+
id: e.id,
|
|
847
|
+
fromId: e.from_id,
|
|
848
|
+
toId: e.to_id,
|
|
849
|
+
kind: e.kind,
|
|
850
|
+
})),
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
// find_concepts
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
// Mirrors the webapp `find_concepts` tool. Webapp version supports
|
|
857
|
+
// by='meaning' (in-browser embeddings) and by='label'. The MCP runs server-
|
|
858
|
+
// side without a local embedding model, so by='meaning' transparently falls
|
|
859
|
+
// back to the same substring search as by='label' and the response carries a
|
|
860
|
+
// `note` explaining the degradation. Phase 19.5 swaps in pgvector when the
|
|
861
|
+
// schema picks it up.
|
|
862
|
+
export const findConceptsInput = z
|
|
863
|
+
.object({
|
|
864
|
+
query: z.string().min(1),
|
|
865
|
+
k: z.number().int().positive().max(20).default(10),
|
|
866
|
+
by: z.enum(['meaning', 'label']).default('meaning'),
|
|
867
|
+
projectId: z.string().optional(),
|
|
868
|
+
})
|
|
869
|
+
.strict();
|
|
870
|
+
export async function findConcepts(client, args) {
|
|
871
|
+
// We just call searchConcepts; the shape is similar but the response
|
|
872
|
+
// contract here matches the webapp tool's response (`hits`, `mode`).
|
|
873
|
+
const sub = await searchConcepts(client, {
|
|
874
|
+
query: args.query,
|
|
875
|
+
limit: args.k,
|
|
876
|
+
detail: 'compact',
|
|
877
|
+
projectId: args.projectId,
|
|
878
|
+
});
|
|
879
|
+
if ('error' in sub)
|
|
880
|
+
return sub;
|
|
881
|
+
const results = (sub.results ?? []);
|
|
882
|
+
const hits = results.map((r) => ({
|
|
883
|
+
id: r.id,
|
|
884
|
+
label: r.label,
|
|
885
|
+
status: r.status,
|
|
886
|
+
score: 1,
|
|
887
|
+
}));
|
|
888
|
+
if (args.by === 'meaning') {
|
|
889
|
+
return {
|
|
890
|
+
hits,
|
|
891
|
+
mode: 'label',
|
|
892
|
+
note: 'Semantic search not yet wired in MCP (no in-browser embedding model); fell back to label/substring match.',
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
return { hits, mode: 'label' };
|
|
896
|
+
}
|
|
897
|
+
// ===========================================================================
|
|
898
|
+
// WRITES — additions (Phase 19.4)
|
|
899
|
+
// ===========================================================================
|
|
900
|
+
// ---------------------------------------------------------------------------
|
|
901
|
+
// validate_concept
|
|
902
|
+
// ---------------------------------------------------------------------------
|
|
903
|
+
export const validateConceptInput = z
|
|
904
|
+
.object({
|
|
905
|
+
id: z.string().min(1),
|
|
906
|
+
rationale: z.string().optional(),
|
|
907
|
+
})
|
|
908
|
+
.strict();
|
|
909
|
+
export async function validateConcept(client, args) {
|
|
910
|
+
const node = unwrap(await client.from('nodes').select('*').eq('id', args.id).maybeSingle());
|
|
911
|
+
if (!node)
|
|
912
|
+
return { error: `No concept with id ${args.id}` };
|
|
913
|
+
const patch = { status: 'validated' };
|
|
914
|
+
if (args.rationale !== undefined)
|
|
915
|
+
patch.rationale = args.rationale;
|
|
916
|
+
const row = unwrap(await client
|
|
917
|
+
.from('nodes')
|
|
918
|
+
.update(patch)
|
|
919
|
+
.eq('id', args.id)
|
|
920
|
+
.select('id, label, status')
|
|
921
|
+
.maybeSingle());
|
|
922
|
+
if (!row)
|
|
923
|
+
return { error: `Update failed for ${args.id}` };
|
|
924
|
+
await stampProvenance(client, {
|
|
925
|
+
nodeId: row.id,
|
|
926
|
+
workspaceId: node.workspace_id,
|
|
927
|
+
sourceRef: 'validate',
|
|
928
|
+
});
|
|
929
|
+
return { id: row.id, label: row.label, status: row.status };
|
|
930
|
+
}
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
// set_standing
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
export const setStandingInput = z
|
|
935
|
+
.object({
|
|
936
|
+
id: z.string().min(1),
|
|
937
|
+
standing: z.enum(['unknown', 'novel', 'emerging', 'established']),
|
|
938
|
+
rationale: z.string().min(1).max(500),
|
|
939
|
+
})
|
|
940
|
+
.strict();
|
|
941
|
+
export async function setStanding(client, args) {
|
|
942
|
+
const node = unwrap(await client
|
|
943
|
+
.from('nodes')
|
|
944
|
+
.select('id, workspace_id, label, standing_rationale')
|
|
945
|
+
.eq('id', args.id)
|
|
946
|
+
.maybeSingle());
|
|
947
|
+
if (!node)
|
|
948
|
+
return { error: `No concept with id ${args.id}` };
|
|
949
|
+
const patch = {
|
|
950
|
+
standing: args.standing,
|
|
951
|
+
standing_rationale: args.rationale,
|
|
952
|
+
standing_assessed_at: new Date().toISOString(),
|
|
953
|
+
};
|
|
954
|
+
const row = unwrap(await client
|
|
955
|
+
.from('nodes')
|
|
956
|
+
.update(patch)
|
|
957
|
+
.eq('id', args.id)
|
|
958
|
+
.select('id, label, standing, standing_rationale')
|
|
959
|
+
.maybeSingle());
|
|
960
|
+
if (!row)
|
|
961
|
+
return { error: `Update failed for ${args.id}` };
|
|
962
|
+
await stampProvenance(client, {
|
|
963
|
+
nodeId: row.id,
|
|
964
|
+
workspaceId: node.workspace_id,
|
|
965
|
+
sourceRef: `standing:${args.standing}`,
|
|
966
|
+
});
|
|
967
|
+
return {
|
|
968
|
+
id: row.id,
|
|
969
|
+
label: row.label,
|
|
970
|
+
standing: row.standing,
|
|
971
|
+
rationale: row.standing_rationale,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
// ---------------------------------------------------------------------------
|
|
975
|
+
// archive_concept / unarchive_concept / star_concept
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
export const archiveConceptInput = z.object({ id: z.string().min(1) }).strict();
|
|
978
|
+
export async function archiveConcept(client, args) {
|
|
979
|
+
const node = unwrap(await client
|
|
980
|
+
.from('nodes')
|
|
981
|
+
.select('id, workspace_id, label, status')
|
|
982
|
+
.eq('id', args.id)
|
|
983
|
+
.maybeSingle());
|
|
984
|
+
if (!node)
|
|
985
|
+
return { error: `No concept with id ${args.id}` };
|
|
986
|
+
if (node.status === 'archived') {
|
|
987
|
+
return { id: node.id, label: node.label, status: node.status, noop: true };
|
|
988
|
+
}
|
|
989
|
+
const row = unwrap(await client
|
|
990
|
+
.from('nodes')
|
|
991
|
+
.update({ status: 'archived' })
|
|
992
|
+
.eq('id', args.id)
|
|
993
|
+
.select('id, label, status')
|
|
994
|
+
.maybeSingle());
|
|
995
|
+
if (!row)
|
|
996
|
+
return { error: `Update failed for ${args.id}` };
|
|
997
|
+
await stampProvenance(client, {
|
|
998
|
+
nodeId: row.id,
|
|
999
|
+
workspaceId: node.workspace_id,
|
|
1000
|
+
sourceRef: 'archive',
|
|
1001
|
+
});
|
|
1002
|
+
return { id: row.id, label: row.label, status: row.status };
|
|
1003
|
+
}
|
|
1004
|
+
export const unarchiveConceptInput = z.object({ id: z.string().min(1) }).strict();
|
|
1005
|
+
export async function unarchiveConcept(client, args) {
|
|
1006
|
+
const node = unwrap(await client
|
|
1007
|
+
.from('nodes')
|
|
1008
|
+
.select('id, workspace_id, label, status')
|
|
1009
|
+
.eq('id', args.id)
|
|
1010
|
+
.maybeSingle());
|
|
1011
|
+
if (!node)
|
|
1012
|
+
return { error: `No concept with id ${args.id}` };
|
|
1013
|
+
if (node.status !== 'archived') {
|
|
1014
|
+
return { id: node.id, label: node.label, status: node.status, noop: true };
|
|
1015
|
+
}
|
|
1016
|
+
const row = unwrap(await client
|
|
1017
|
+
.from('nodes')
|
|
1018
|
+
.update({ status: 'open' })
|
|
1019
|
+
.eq('id', args.id)
|
|
1020
|
+
.select('id, label, status')
|
|
1021
|
+
.maybeSingle());
|
|
1022
|
+
if (!row)
|
|
1023
|
+
return { error: `Update failed for ${args.id}` };
|
|
1024
|
+
await stampProvenance(client, {
|
|
1025
|
+
nodeId: row.id,
|
|
1026
|
+
workspaceId: node.workspace_id,
|
|
1027
|
+
sourceRef: 'unarchive',
|
|
1028
|
+
});
|
|
1029
|
+
return { id: row.id, label: row.label, status: row.status };
|
|
1030
|
+
}
|
|
1031
|
+
export const starConceptInput = z.object({ id: z.string().min(1) }).strict();
|
|
1032
|
+
export async function starConcept(client, args) {
|
|
1033
|
+
const node = unwrap(await client
|
|
1034
|
+
.from('nodes')
|
|
1035
|
+
.select('id, workspace_id, label, starred')
|
|
1036
|
+
.eq('id', args.id)
|
|
1037
|
+
.maybeSingle());
|
|
1038
|
+
if (!node)
|
|
1039
|
+
return { error: `No concept with id ${args.id}` };
|
|
1040
|
+
const next = !node.starred;
|
|
1041
|
+
const row = unwrap(await client
|
|
1042
|
+
.from('nodes')
|
|
1043
|
+
.update({ starred: next })
|
|
1044
|
+
.eq('id', args.id)
|
|
1045
|
+
.select('id, label, starred')
|
|
1046
|
+
.maybeSingle());
|
|
1047
|
+
if (!row)
|
|
1048
|
+
return { error: `Update failed for ${args.id}` };
|
|
1049
|
+
await stampProvenance(client, {
|
|
1050
|
+
nodeId: row.id,
|
|
1051
|
+
workspaceId: node.workspace_id,
|
|
1052
|
+
sourceRef: next ? 'star' : 'unstar',
|
|
1053
|
+
});
|
|
1054
|
+
return { id: row.id, label: row.label, starred: row.starred };
|
|
1055
|
+
}
|
|
1056
|
+
// ---------------------------------------------------------------------------
|
|
1057
|
+
// remove_concept (cascade across the parent_id tree)
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
// Cloud FKs:
|
|
1060
|
+
// nodes.parent_id ON DELETE SET NULL (children become orphans, NOT auto-
|
|
1061
|
+
// deleted by Postgres)
|
|
1062
|
+
// edges.from_id/to_id ON DELETE CASCADE
|
|
1063
|
+
// project_nodes / idea_nodes ON DELETE CASCADE
|
|
1064
|
+
// So to match the webapp's `removeNode` cascade we walk the parent_id graph
|
|
1065
|
+
// in code, collect every descendant, and delete the bottom-up set.
|
|
1066
|
+
export const removeConceptInput = z.object({ id: z.string().min(1) }).strict();
|
|
1067
|
+
export async function removeConcept(client, args) {
|
|
1068
|
+
const node = unwrap(await client
|
|
1069
|
+
.from('nodes')
|
|
1070
|
+
.select('id, workspace_id, label')
|
|
1071
|
+
.eq('id', args.id)
|
|
1072
|
+
.maybeSingle());
|
|
1073
|
+
if (!node)
|
|
1074
|
+
return { error: `No concept with id ${args.id}` };
|
|
1075
|
+
// Refuse to delete a project's root.
|
|
1076
|
+
const isRoot = unwrap(await client
|
|
1077
|
+
.from('projects')
|
|
1078
|
+
.select('id')
|
|
1079
|
+
.eq('root_node_id', args.id)
|
|
1080
|
+
.limit(1));
|
|
1081
|
+
if (isRoot.length > 0) {
|
|
1082
|
+
return { error: 'Cannot delete a project root node.' };
|
|
1083
|
+
}
|
|
1084
|
+
// Walk the parent_id subtree in memory (cheaper than depth round-trips).
|
|
1085
|
+
const allNodes = unwrap(await client
|
|
1086
|
+
.from('nodes')
|
|
1087
|
+
.select('id, parent_id')
|
|
1088
|
+
.eq('workspace_id', node.workspace_id));
|
|
1089
|
+
const childrenByParent = new Map();
|
|
1090
|
+
for (const n of allNodes) {
|
|
1091
|
+
if (n.parent_id) {
|
|
1092
|
+
const arr = childrenByParent.get(n.parent_id) ?? [];
|
|
1093
|
+
arr.push(n.id);
|
|
1094
|
+
childrenByParent.set(n.parent_id, arr);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
const toDelete = [];
|
|
1098
|
+
const stack = [args.id];
|
|
1099
|
+
while (stack.length > 0) {
|
|
1100
|
+
const cur = stack.pop();
|
|
1101
|
+
toDelete.push(cur);
|
|
1102
|
+
const kids = childrenByParent.get(cur) ?? [];
|
|
1103
|
+
stack.push(...kids);
|
|
1104
|
+
}
|
|
1105
|
+
// Delete in one shot — edges/junctions cascade via FK; provenance
|
|
1106
|
+
// cascades via FK in 0015's table definition.
|
|
1107
|
+
const del = await client.from('nodes').delete().in('id', toDelete);
|
|
1108
|
+
if (del.error)
|
|
1109
|
+
return { error: del.error.message };
|
|
1110
|
+
return {
|
|
1111
|
+
id: args.id,
|
|
1112
|
+
removed: true,
|
|
1113
|
+
cascadeCount: toDelete.length,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
// ---------------------------------------------------------------------------
|
|
1117
|
+
// bulk_add_concepts (atomic via fn_bulk_add_concepts RPC)
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
// Each child resolves its project_id either explicitly (via the item's
|
|
1120
|
+
// `projectId`), via the parent's home project, or via the workspace's only
|
|
1121
|
+
// project. Resolution happens here (the RPC requires project_id on every
|
|
1122
|
+
// item) and the RPC then guarantees atomicity across the multi-row insert.
|
|
1123
|
+
export const bulkAddConceptsInput = z
|
|
1124
|
+
.object({
|
|
1125
|
+
items: z
|
|
1126
|
+
.array(z.object({
|
|
1127
|
+
label: z.string().min(1).max(140),
|
|
1128
|
+
description: z.string().max(2000).optional(),
|
|
1129
|
+
parentId: z.string().nullable().optional(),
|
|
1130
|
+
projectId: z.string().optional(),
|
|
1131
|
+
tags: z.array(z.string()).optional(),
|
|
1132
|
+
}))
|
|
1133
|
+
.min(1)
|
|
1134
|
+
.max(200),
|
|
1135
|
+
})
|
|
1136
|
+
.strict();
|
|
1137
|
+
export async function bulkAddConcepts(client, args) {
|
|
1138
|
+
const wsId = await resolveWorkspaceId(client);
|
|
1139
|
+
// Pre-resolve every item's project_id so the RPC has the simple shape.
|
|
1140
|
+
const parentIds = Array.from(new Set(args.items
|
|
1141
|
+
.map((i) => i.parentId)
|
|
1142
|
+
.filter((p) => typeof p === 'string' && p.length > 0)));
|
|
1143
|
+
const parentRows = parentIds.length
|
|
1144
|
+
? unwrap(await client.from('nodes').select('id, project_id').in('id', parentIds))
|
|
1145
|
+
: [];
|
|
1146
|
+
const parentProject = new Map(parentRows.map((p) => [p.id, p.project_id]));
|
|
1147
|
+
// Workspace's solo project, used when nothing else resolves.
|
|
1148
|
+
const projectRows = unwrap(await client.from('projects').select('id').eq('workspace_id', wsId));
|
|
1149
|
+
const soloProjectId = projectRows.length === 1 ? projectRows[0].id : null;
|
|
1150
|
+
const resolved = [];
|
|
1151
|
+
for (const item of args.items) {
|
|
1152
|
+
let projectId = item.projectId ?? null;
|
|
1153
|
+
if (!projectId && item.parentId) {
|
|
1154
|
+
projectId = parentProject.get(item.parentId) ?? null;
|
|
1155
|
+
}
|
|
1156
|
+
if (!projectId)
|
|
1157
|
+
projectId = soloProjectId;
|
|
1158
|
+
if (!projectId) {
|
|
1159
|
+
return {
|
|
1160
|
+
error: `Could not resolve projectId for item "${item.label}" — workspace has ${projectRows.length} projects, no parent to inherit from.`,
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
resolved.push({
|
|
1164
|
+
parent_id: item.parentId ?? null,
|
|
1165
|
+
label: item.label,
|
|
1166
|
+
description: item.description ?? '',
|
|
1167
|
+
tags: item.tags ?? [],
|
|
1168
|
+
project_id: projectId,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
const rpc = await client.rpc('fn_bulk_add_concepts', {
|
|
1172
|
+
p_items: resolved,
|
|
1173
|
+
});
|
|
1174
|
+
if (rpc.error)
|
|
1175
|
+
return { error: rpc.error.message };
|
|
1176
|
+
const created = (rpc.data ?? []);
|
|
1177
|
+
// Best-effort provenance for each new node.
|
|
1178
|
+
for (const c of created) {
|
|
1179
|
+
await stampProvenance(client, {
|
|
1180
|
+
nodeId: c.id,
|
|
1181
|
+
workspaceId: wsId,
|
|
1182
|
+
sourceRef: 'bulk-add',
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
created: created.map((c) => ({
|
|
1187
|
+
id: c.id,
|
|
1188
|
+
label: c.label,
|
|
1189
|
+
parentId: c.parent_id,
|
|
1190
|
+
projectId: c.project_id,
|
|
1191
|
+
})),
|
|
1192
|
+
count: created.length,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
// ---------------------------------------------------------------------------
|
|
1196
|
+
// set_parent (move a node + sync partition edge)
|
|
1197
|
+
// ---------------------------------------------------------------------------
|
|
1198
|
+
// Mirrors webapp `set_parent` / store.moveNodeParent: updates parent_id AND
|
|
1199
|
+
// drops any pre-existing partition edge whose target is this node, then
|
|
1200
|
+
// inserts a fresh partition edge from the new parent if non-null.
|
|
1201
|
+
export const setParentInput = z
|
|
1202
|
+
.object({
|
|
1203
|
+
nodeId: z.string().min(1),
|
|
1204
|
+
newParentId: z.string().nullable(),
|
|
1205
|
+
})
|
|
1206
|
+
.strict();
|
|
1207
|
+
export async function setParent(client, args) {
|
|
1208
|
+
const node = unwrap(await client
|
|
1209
|
+
.from('nodes')
|
|
1210
|
+
.select('id, workspace_id, parent_id, label')
|
|
1211
|
+
.eq('id', args.nodeId)
|
|
1212
|
+
.maybeSingle());
|
|
1213
|
+
if (!node)
|
|
1214
|
+
return { error: `No concept with id ${args.nodeId}` };
|
|
1215
|
+
if (args.newParentId && args.newParentId === args.nodeId) {
|
|
1216
|
+
return { error: 'Cannot parent a node to itself.' };
|
|
1217
|
+
}
|
|
1218
|
+
let resolvedParentId = args.newParentId;
|
|
1219
|
+
if (resolvedParentId) {
|
|
1220
|
+
const parent = unwrap(await client
|
|
1221
|
+
.from('nodes')
|
|
1222
|
+
.select('id, workspace_id')
|
|
1223
|
+
.eq('id', resolvedParentId)
|
|
1224
|
+
.maybeSingle());
|
|
1225
|
+
if (!parent)
|
|
1226
|
+
return { error: `New parent ${resolvedParentId} not found.` };
|
|
1227
|
+
if (parent.workspace_id !== node.workspace_id) {
|
|
1228
|
+
return { error: 'Cannot re-parent across workspaces.' };
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// 1) Update parent_id.
|
|
1232
|
+
const updRes = await client
|
|
1233
|
+
.from('nodes')
|
|
1234
|
+
.update({ parent_id: resolvedParentId })
|
|
1235
|
+
.eq('id', args.nodeId);
|
|
1236
|
+
if (updRes.error)
|
|
1237
|
+
return { error: updRes.error.message };
|
|
1238
|
+
// 2) Delete every existing partition edge whose target is this node.
|
|
1239
|
+
const delRes = await client
|
|
1240
|
+
.from('edges')
|
|
1241
|
+
.delete()
|
|
1242
|
+
.eq('to_id', args.nodeId)
|
|
1243
|
+
.eq('kind', 'partition');
|
|
1244
|
+
if (delRes.error)
|
|
1245
|
+
return { error: delRes.error.message };
|
|
1246
|
+
// 3) Insert a fresh partition edge from the new parent (if any).
|
|
1247
|
+
if (resolvedParentId) {
|
|
1248
|
+
const insRes = await client.from('edges').insert({
|
|
1249
|
+
workspace_id: node.workspace_id,
|
|
1250
|
+
from_id: resolvedParentId,
|
|
1251
|
+
to_id: args.nodeId,
|
|
1252
|
+
kind: 'partition',
|
|
1253
|
+
});
|
|
1254
|
+
if (insRes.error)
|
|
1255
|
+
return { error: insRes.error.message };
|
|
1256
|
+
}
|
|
1257
|
+
await stampProvenance(client, {
|
|
1258
|
+
nodeId: args.nodeId,
|
|
1259
|
+
workspaceId: node.workspace_id,
|
|
1260
|
+
sourceRef: 'set-parent',
|
|
1261
|
+
});
|
|
1262
|
+
return {
|
|
1263
|
+
ok: true,
|
|
1264
|
+
nodeId: args.nodeId,
|
|
1265
|
+
newParentId: resolvedParentId,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
// ---------------------------------------------------------------------------
|
|
1269
|
+
// add_kref — convenience alias of link_concepts kind='k-ref'.
|
|
1270
|
+
// ---------------------------------------------------------------------------
|
|
1271
|
+
export const addKrefInput = z
|
|
1272
|
+
.object({
|
|
1273
|
+
fromId: z.string().min(1),
|
|
1274
|
+
toId: z.string().min(1),
|
|
1275
|
+
})
|
|
1276
|
+
.strict();
|
|
1277
|
+
export async function addKref(client, args) {
|
|
1278
|
+
return linkConcepts(client, {
|
|
1279
|
+
fromId: args.fromId,
|
|
1280
|
+
toId: args.toId,
|
|
1281
|
+
kind: 'k-ref',
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
// ---------------------------------------------------------------------------
|
|
1285
|
+
// IDEAS — create_idea, rename_idea, recolor_idea, set_idea_members,
|
|
1286
|
+
// add_to_idea, delete_idea
|
|
1287
|
+
// ---------------------------------------------------------------------------
|
|
1288
|
+
// Idea ↔ node membership lives in `idea_nodes`. `set_idea_members` follows
|
|
1289
|
+
// the reconcileJunctionForParent pattern from src/data/cloud-sync.ts: upsert
|
|
1290
|
+
// the desired pairs, then delete the rows no longer in the set.
|
|
1291
|
+
const DEFAULT_IDEA_PALETTE = [
|
|
1292
|
+
'#ff6b6b',
|
|
1293
|
+
'#ffd166',
|
|
1294
|
+
'#06d6a0',
|
|
1295
|
+
'#118ab2',
|
|
1296
|
+
'#9b5de5',
|
|
1297
|
+
'#f15bb5',
|
|
1298
|
+
'#00bbf9',
|
|
1299
|
+
'#fee440',
|
|
1300
|
+
];
|
|
1301
|
+
export const createIdeaInput = z
|
|
1302
|
+
.object({
|
|
1303
|
+
name: z.string().min(1).max(140),
|
|
1304
|
+
color: z.string().optional(),
|
|
1305
|
+
nodeIds: z.array(z.string()).optional(),
|
|
1306
|
+
})
|
|
1307
|
+
.strict();
|
|
1308
|
+
export async function createIdea(client, args) {
|
|
1309
|
+
const wsId = await resolveWorkspaceId(client);
|
|
1310
|
+
// Pick a default color if none given. Webapp uses an 8-color palette
|
|
1311
|
+
// indexed by current idea count; do the same here.
|
|
1312
|
+
let color = args.color;
|
|
1313
|
+
if (!color) {
|
|
1314
|
+
const existing = unwrap(await client
|
|
1315
|
+
.from('ideas')
|
|
1316
|
+
.select('id', { count: 'exact', head: true })
|
|
1317
|
+
.eq('workspace_id', wsId));
|
|
1318
|
+
void existing;
|
|
1319
|
+
// The head:true query returns count via the result envelope; we just
|
|
1320
|
+
// index modulo palette length using a fresh count query.
|
|
1321
|
+
const c = await client
|
|
1322
|
+
.from('ideas')
|
|
1323
|
+
.select('id', { count: 'exact', head: true })
|
|
1324
|
+
.eq('workspace_id', wsId);
|
|
1325
|
+
color = DEFAULT_IDEA_PALETTE[(c.count ?? 0) % DEFAULT_IDEA_PALETTE.length];
|
|
1326
|
+
}
|
|
1327
|
+
const idea = unwrap(await client
|
|
1328
|
+
.from('ideas')
|
|
1329
|
+
.insert({ workspace_id: wsId, name: args.name, color })
|
|
1330
|
+
.select('id, name, color')
|
|
1331
|
+
.single());
|
|
1332
|
+
// Insert membership rows.
|
|
1333
|
+
const nodeIds = args.nodeIds ?? [];
|
|
1334
|
+
if (nodeIds.length > 0) {
|
|
1335
|
+
const rows = nodeIds.map((nid, i) => ({
|
|
1336
|
+
idea_id: idea.id,
|
|
1337
|
+
node_id: nid,
|
|
1338
|
+
position: i,
|
|
1339
|
+
}));
|
|
1340
|
+
const ins = await client.from('idea_nodes').insert(rows);
|
|
1341
|
+
if (ins.error)
|
|
1342
|
+
return { error: ins.error.message };
|
|
1343
|
+
}
|
|
1344
|
+
return { id: idea.id, name: idea.name, color: idea.color, nodeCount: nodeIds.length };
|
|
1345
|
+
}
|
|
1346
|
+
export const renameIdeaInput = z
|
|
1347
|
+
.object({ id: z.string().min(1), name: z.string().min(1).max(140) })
|
|
1348
|
+
.strict();
|
|
1349
|
+
export async function renameIdea(client, args) {
|
|
1350
|
+
const row = unwrap(await client
|
|
1351
|
+
.from('ideas')
|
|
1352
|
+
.update({ name: args.name })
|
|
1353
|
+
.eq('id', args.id)
|
|
1354
|
+
.select('id, name')
|
|
1355
|
+
.maybeSingle());
|
|
1356
|
+
if (!row)
|
|
1357
|
+
return { error: `No idea with id ${args.id}` };
|
|
1358
|
+
return { id: row.id, name: row.name };
|
|
1359
|
+
}
|
|
1360
|
+
export const recolorIdeaInput = z
|
|
1361
|
+
.object({ id: z.string().min(1), color: z.string().min(1) })
|
|
1362
|
+
.strict();
|
|
1363
|
+
export async function recolorIdea(client, args) {
|
|
1364
|
+
const row = unwrap(await client
|
|
1365
|
+
.from('ideas')
|
|
1366
|
+
.update({ color: args.color })
|
|
1367
|
+
.eq('id', args.id)
|
|
1368
|
+
.select('id, name, color')
|
|
1369
|
+
.maybeSingle());
|
|
1370
|
+
if (!row)
|
|
1371
|
+
return { error: `No idea with id ${args.id}` };
|
|
1372
|
+
return { id: row.id, name: row.name, color: row.color };
|
|
1373
|
+
}
|
|
1374
|
+
export const setIdeaMembersInput = z
|
|
1375
|
+
.object({
|
|
1376
|
+
ideaId: z.string().min(1),
|
|
1377
|
+
nodeIds: z.array(z.string()),
|
|
1378
|
+
})
|
|
1379
|
+
.strict();
|
|
1380
|
+
export async function setIdeaMembers(client, args) {
|
|
1381
|
+
const idea = unwrap(await client
|
|
1382
|
+
.from('ideas')
|
|
1383
|
+
.select('id, name')
|
|
1384
|
+
.eq('id', args.ideaId)
|
|
1385
|
+
.maybeSingle());
|
|
1386
|
+
if (!idea)
|
|
1387
|
+
return { error: `No idea with id ${args.ideaId}` };
|
|
1388
|
+
const desiredOrder = new Map(args.nodeIds.map((n, i) => [n, i]));
|
|
1389
|
+
const existing = unwrap(await client
|
|
1390
|
+
.from('idea_nodes')
|
|
1391
|
+
.select('node_id')
|
|
1392
|
+
.eq('idea_id', args.ideaId));
|
|
1393
|
+
const existingSet = new Set(existing.map((r) => r.node_id));
|
|
1394
|
+
// Upsert desired rows.
|
|
1395
|
+
const upserts = [...desiredOrder.entries()].map(([nid, position]) => ({
|
|
1396
|
+
idea_id: args.ideaId,
|
|
1397
|
+
node_id: nid,
|
|
1398
|
+
position,
|
|
1399
|
+
}));
|
|
1400
|
+
if (upserts.length > 0) {
|
|
1401
|
+
const up = await client
|
|
1402
|
+
.from('idea_nodes')
|
|
1403
|
+
.upsert(upserts, { onConflict: 'idea_id,node_id' });
|
|
1404
|
+
if (up.error)
|
|
1405
|
+
return { error: up.error.message };
|
|
1406
|
+
}
|
|
1407
|
+
// Delete rows not in the desired set.
|
|
1408
|
+
const toRemove = [...existingSet].filter((nid) => !desiredOrder.has(nid));
|
|
1409
|
+
if (toRemove.length > 0) {
|
|
1410
|
+
const del = await client
|
|
1411
|
+
.from('idea_nodes')
|
|
1412
|
+
.delete()
|
|
1413
|
+
.eq('idea_id', args.ideaId)
|
|
1414
|
+
.in('node_id', toRemove);
|
|
1415
|
+
if (del.error)
|
|
1416
|
+
return { error: del.error.message };
|
|
1417
|
+
}
|
|
1418
|
+
return {
|
|
1419
|
+
id: args.ideaId,
|
|
1420
|
+
name: idea.name,
|
|
1421
|
+
nodeCount: desiredOrder.size,
|
|
1422
|
+
added: [...desiredOrder.keys()].filter((n) => !existingSet.has(n)).length,
|
|
1423
|
+
removed: toRemove.length,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
export const addToIdeaInput = z
|
|
1427
|
+
.object({
|
|
1428
|
+
ideaId: z.string().min(1),
|
|
1429
|
+
nodeId: z.string().min(1),
|
|
1430
|
+
})
|
|
1431
|
+
.strict();
|
|
1432
|
+
export async function addToIdea(client, args) {
|
|
1433
|
+
const idea = unwrap(await client
|
|
1434
|
+
.from('ideas')
|
|
1435
|
+
.select('id')
|
|
1436
|
+
.eq('id', args.ideaId)
|
|
1437
|
+
.maybeSingle());
|
|
1438
|
+
if (!idea)
|
|
1439
|
+
return { error: `No idea with id ${args.ideaId}` };
|
|
1440
|
+
const node = unwrap(await client
|
|
1441
|
+
.from('nodes')
|
|
1442
|
+
.select('id')
|
|
1443
|
+
.eq('id', args.nodeId)
|
|
1444
|
+
.maybeSingle());
|
|
1445
|
+
if (!node)
|
|
1446
|
+
return { error: `No concept with id ${args.nodeId}` };
|
|
1447
|
+
const ins = await client
|
|
1448
|
+
.from('idea_nodes')
|
|
1449
|
+
.upsert({ idea_id: args.ideaId, node_id: args.nodeId, position: 0 }, { onConflict: 'idea_id,node_id' });
|
|
1450
|
+
if (ins.error)
|
|
1451
|
+
return { error: ins.error.message };
|
|
1452
|
+
return { ideaId: args.ideaId, nodeId: args.nodeId };
|
|
1453
|
+
}
|
|
1454
|
+
export const deleteIdeaInput = z.object({ id: z.string().min(1) }).strict();
|
|
1455
|
+
export async function deleteIdea(client, args) {
|
|
1456
|
+
const del = await client.from('ideas').delete().eq('id', args.id);
|
|
1457
|
+
if (del.error)
|
|
1458
|
+
return { error: del.error.message };
|
|
1459
|
+
return { id: args.id, deleted: true };
|
|
1460
|
+
}
|
|
1461
|
+
// ---------------------------------------------------------------------------
|
|
1462
|
+
// PROJECTS — create_project, update_project, delete_project
|
|
1463
|
+
// ---------------------------------------------------------------------------
|
|
1464
|
+
export const createProjectInput = z
|
|
1465
|
+
.object({
|
|
1466
|
+
name: z.string().min(1).max(140),
|
|
1467
|
+
brief: z.string().max(2000).default(''),
|
|
1468
|
+
direction: z.string().max(2000).optional(),
|
|
1469
|
+
})
|
|
1470
|
+
.strict();
|
|
1471
|
+
export async function createProject(client, args) {
|
|
1472
|
+
const wsId = await resolveWorkspaceId(client);
|
|
1473
|
+
const row = unwrap(await client
|
|
1474
|
+
.from('projects')
|
|
1475
|
+
.insert({
|
|
1476
|
+
workspace_id: wsId,
|
|
1477
|
+
name: args.name,
|
|
1478
|
+
brief: args.brief,
|
|
1479
|
+
direction: args.direction ?? null,
|
|
1480
|
+
})
|
|
1481
|
+
.select('id, name, brief, direction, lifecycle')
|
|
1482
|
+
.single());
|
|
1483
|
+
return {
|
|
1484
|
+
id: row.id,
|
|
1485
|
+
name: row.name,
|
|
1486
|
+
brief: row.brief,
|
|
1487
|
+
direction: row.direction,
|
|
1488
|
+
lifecycle: row.lifecycle,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
export const updateProjectInput = z
|
|
1492
|
+
.object({
|
|
1493
|
+
id: z.string().min(1),
|
|
1494
|
+
name: z.string().min(1).max(140).optional(),
|
|
1495
|
+
brief: z.string().max(2000).optional(),
|
|
1496
|
+
direction: z.string().max(2000).optional(),
|
|
1497
|
+
lifecycle: z.enum(['active', 'paused', 'completed', 'abandoned']).optional(),
|
|
1498
|
+
})
|
|
1499
|
+
.strict();
|
|
1500
|
+
export async function updateProject(client, args) {
|
|
1501
|
+
const patch = {};
|
|
1502
|
+
if (args.name !== undefined)
|
|
1503
|
+
patch.name = args.name;
|
|
1504
|
+
if (args.brief !== undefined)
|
|
1505
|
+
patch.brief = args.brief;
|
|
1506
|
+
if (args.direction !== undefined)
|
|
1507
|
+
patch.direction = args.direction;
|
|
1508
|
+
if (args.lifecycle !== undefined)
|
|
1509
|
+
patch.lifecycle = args.lifecycle;
|
|
1510
|
+
if (Object.keys(patch).length === 0) {
|
|
1511
|
+
return { error: 'No fields to update.' };
|
|
1512
|
+
}
|
|
1513
|
+
const row = unwrap(await client
|
|
1514
|
+
.from('projects')
|
|
1515
|
+
.update(patch)
|
|
1516
|
+
.eq('id', args.id)
|
|
1517
|
+
.select('id, name, brief, direction, lifecycle')
|
|
1518
|
+
.maybeSingle());
|
|
1519
|
+
if (!row)
|
|
1520
|
+
return { error: `No project with id ${args.id}` };
|
|
1521
|
+
return {
|
|
1522
|
+
id: row.id,
|
|
1523
|
+
name: row.name,
|
|
1524
|
+
brief: row.brief,
|
|
1525
|
+
direction: row.direction,
|
|
1526
|
+
lifecycle: row.lifecycle,
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
export const deleteProjectInput = z.object({ id: z.string().min(1) }).strict();
|
|
1530
|
+
export async function deleteProject(client, args) {
|
|
1531
|
+
const del = await client.from('projects').delete().eq('id', args.id);
|
|
1532
|
+
if (del.error)
|
|
1533
|
+
return { error: del.error.message };
|
|
1534
|
+
return { id: args.id, deleted: true };
|
|
1535
|
+
}
|
|
1536
|
+
export const CLOUD_TOOLS = [
|
|
1537
|
+
// ── Reads ──────────────────────────────────────────────────────────────
|
|
1538
|
+
{
|
|
1539
|
+
name: 'list_projects',
|
|
1540
|
+
description: 'Every project in the workspace with brief, direction, lifecycle, and node count. Use this to figure out which project the user is asking about.',
|
|
1541
|
+
inputSchema: listProjectsInput,
|
|
1542
|
+
handler: async (client) => listProjects(client),
|
|
1543
|
+
},
|
|
1544
|
+
{
|
|
1545
|
+
name: 'get_project_graph',
|
|
1546
|
+
description: 'Every node + edge inside one project. Returns a graph the agent can reason over end-to-end. Use for project-wide questions.',
|
|
1547
|
+
inputSchema: getProjectGraphInput,
|
|
1548
|
+
handler: async (client, args) => getProjectGraph(client, getProjectGraphInput.parse(args)),
|
|
1549
|
+
},
|
|
1550
|
+
{
|
|
1551
|
+
name: 'get_subtree',
|
|
1552
|
+
description: 'A node and its descendants up to a given depth. Use when the user asks "tell me about everything under X".',
|
|
1553
|
+
inputSchema: getSubtreeInput,
|
|
1554
|
+
handler: async (client, args) => getSubtree(client, getSubtreeInput.parse(args)),
|
|
1555
|
+
},
|
|
1556
|
+
{
|
|
1557
|
+
name: 'get_concept',
|
|
1558
|
+
description: 'One concept by id, optionally with its ancestry (parent chain), direct children, and idea memberships.',
|
|
1559
|
+
inputSchema: getConceptInput,
|
|
1560
|
+
handler: async (client, args) => getConcept(client, getConceptInput.parse(args)),
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
name: 'search_concepts',
|
|
1564
|
+
description: "Substring search across concept labels, descriptions, partition attributes, rationale, and tags. Use this when the user mentions a concept by name and you don't have its id. (Semantic search is a Phase 19.5 enhancement.)",
|
|
1565
|
+
inputSchema: searchConceptsInput,
|
|
1566
|
+
handler: async (client, args) => searchConcepts(client, searchConceptsInput.parse(args)),
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
name: 'get_workspace_summary',
|
|
1570
|
+
description: "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.",
|
|
1571
|
+
inputSchema: getWorkspaceSummaryInput,
|
|
1572
|
+
handler: async (client) => getWorkspaceSummary(client),
|
|
1573
|
+
},
|
|
1574
|
+
{
|
|
1575
|
+
name: 'list_recent_decisions',
|
|
1576
|
+
description: 'Nodes the user has explicitly resolved (validated, starred, or archived) recently. Use to surface "what did we conclude last week" without a full scan.',
|
|
1577
|
+
inputSchema: listRecentDecisionsInput,
|
|
1578
|
+
handler: async (client, args) => listRecentDecisions(client, listRecentDecisionsInput.parse(args)),
|
|
1579
|
+
},
|
|
1580
|
+
{
|
|
1581
|
+
name: 'list_concepts',
|
|
1582
|
+
description: 'List concepts in the workspace or one project. Returns id / label / status / parentId / tags up to `limit` entries (default 500). Use BEFORE making changes to learn what already exists.',
|
|
1583
|
+
inputSchema: listConceptsInput,
|
|
1584
|
+
handler: async (client, args) => listConcepts(client, listConceptsInput.parse(args)),
|
|
1585
|
+
},
|
|
1586
|
+
{
|
|
1587
|
+
name: 'list_edges',
|
|
1588
|
+
description: 'List every edge in the workspace (or restricted to a project). Filter by kind: partition / k-ref / semantic-adjacency / derived-from / imported-from.',
|
|
1589
|
+
inputSchema: listEdgesInput,
|
|
1590
|
+
handler: async (client, args) => listEdges(client, listEdgesInput.parse(args)),
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
name: 'find_concepts',
|
|
1594
|
+
description: "Find concepts by label/substring match. (Webapp parity: by='meaning' is accepted but currently falls back to label match on the MCP — semantic search ships in Phase 19.5.) Returns up to k hits, each with id, label, status.",
|
|
1595
|
+
inputSchema: findConceptsInput,
|
|
1596
|
+
handler: async (client, args) => findConcepts(client, findConceptsInput.parse(args)),
|
|
1597
|
+
},
|
|
1598
|
+
// ── Writes ─────────────────────────────────────────────────────────────
|
|
1599
|
+
{
|
|
1600
|
+
name: 'add_concept',
|
|
1601
|
+
description: 'Create a new concept (Node). If parentId is given, the concept becomes a CHILD of that concept and a partition edge is auto-created. If parentId is omitted, the concept attaches to the project root.',
|
|
1602
|
+
inputSchema: addConceptInput,
|
|
1603
|
+
handler: async (client, args) => addConcept(client, addConceptInput.parse(args)),
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
name: 'update_concept',
|
|
1607
|
+
description: "Update an existing concept's label, description, tags, partitionAttribute, rationale, or status. Only the fields provided are changed.",
|
|
1608
|
+
inputSchema: updateConceptInput,
|
|
1609
|
+
handler: async (client, args) => updateConcept(client, updateConceptInput.parse(args)),
|
|
1610
|
+
},
|
|
1611
|
+
{
|
|
1612
|
+
name: 'link_concepts',
|
|
1613
|
+
description: "Create an edge between two concepts. kind is one of: 'k-ref' (knowledge reference), 'derived-from' (provenance link), 'semantic-adjacency' (sibling kinship). Partition (parent/child) edges are managed via add_concept / set_parent.",
|
|
1614
|
+
inputSchema: linkConceptsInput,
|
|
1615
|
+
handler: async (client, args) => linkConcepts(client, linkConceptsInput.parse(args)),
|
|
1616
|
+
},
|
|
1617
|
+
{
|
|
1618
|
+
name: 'add_kref',
|
|
1619
|
+
description: 'Convenience: create a k-ref (knowledge reference) edge from fromId → toId. Equivalent to link_concepts with kind=k-ref.',
|
|
1620
|
+
inputSchema: addKrefInput,
|
|
1621
|
+
handler: async (client, args) => addKref(client, addKrefInput.parse(args)),
|
|
1622
|
+
},
|
|
1623
|
+
{
|
|
1624
|
+
name: 'validate_concept',
|
|
1625
|
+
description: 'Mark a concept as validated (status=validated). Optionally also overwrite `rationale`. Idempotent on already-validated nodes.',
|
|
1626
|
+
inputSchema: validateConceptInput,
|
|
1627
|
+
handler: async (client, args) => validateConcept(client, validateConceptInput.parse(args)),
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
name: 'set_standing',
|
|
1631
|
+
description: "Set a concept's STANDING — how known / proven the idea is in the wider world. `standing` ∈ 'novel' | 'emerging' | 'established' | 'unknown'. `rationale` is a one-sentence justification (saved as `standing_rationale`). Use this when classifying / judging novelty.",
|
|
1632
|
+
inputSchema: setStandingInput,
|
|
1633
|
+
handler: async (client, args) => setStanding(client, setStandingInput.parse(args)),
|
|
1634
|
+
},
|
|
1635
|
+
{
|
|
1636
|
+
name: 'archive_concept',
|
|
1637
|
+
description: 'Archive a concept (status=archived). Reversible via unarchive_concept.',
|
|
1638
|
+
inputSchema: archiveConceptInput,
|
|
1639
|
+
handler: async (client, args) => archiveConcept(client, archiveConceptInput.parse(args)),
|
|
1640
|
+
},
|
|
1641
|
+
{
|
|
1642
|
+
name: 'unarchive_concept',
|
|
1643
|
+
description: 'Revive a previously-archived concept (status=open). Idempotent on already-open nodes.',
|
|
1644
|
+
inputSchema: unarchiveConceptInput,
|
|
1645
|
+
handler: async (client, args) => unarchiveConcept(client, unarchiveConceptInput.parse(args)),
|
|
1646
|
+
},
|
|
1647
|
+
{
|
|
1648
|
+
name: 'star_concept',
|
|
1649
|
+
description: 'Toggle the starred flag on a concept (user-favorite marker).',
|
|
1650
|
+
inputSchema: starConceptInput,
|
|
1651
|
+
handler: async (client, args) => starConcept(client, starConceptInput.parse(args)),
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
name: 'remove_concept',
|
|
1655
|
+
description: 'Hard-delete a concept and its descendants (walks the parent_id subtree). Edges and project/idea memberships cascade via FK. Refuses to delete a project root node.',
|
|
1656
|
+
inputSchema: removeConceptInput,
|
|
1657
|
+
handler: async (client, args) => removeConcept(client, removeConceptInput.parse(args)),
|
|
1658
|
+
},
|
|
1659
|
+
{
|
|
1660
|
+
name: 'bulk_add_concepts',
|
|
1661
|
+
description: 'Create many concepts in a single atomic transaction. `items` is an array; each item carries label / description? / parentId? / projectId? / tags?. project_id is resolved per-item (explicit > inherited from parent > workspace solo project). Returns the created ids in input order.',
|
|
1662
|
+
inputSchema: bulkAddConceptsInput,
|
|
1663
|
+
handler: async (client, args) => bulkAddConcepts(client, bulkAddConceptsInput.parse(args)),
|
|
1664
|
+
},
|
|
1665
|
+
{
|
|
1666
|
+
name: 'set_parent',
|
|
1667
|
+
description: "Re-parent an existing concept: nodeId's parent becomes newParentId (or detached when null). Updates BOTH node.parent_id AND the partition edge so the canvas tree stays in sync.",
|
|
1668
|
+
inputSchema: setParentInput,
|
|
1669
|
+
handler: async (client, args) => setParent(client, setParentInput.parse(args)),
|
|
1670
|
+
},
|
|
1671
|
+
// Ideas
|
|
1672
|
+
{
|
|
1673
|
+
name: 'create_idea',
|
|
1674
|
+
description: 'Create a new Idea (a named grouping of concepts) with an optional color and optional starting node membership.',
|
|
1675
|
+
inputSchema: createIdeaInput,
|
|
1676
|
+
handler: async (client, args) => createIdea(client, createIdeaInput.parse(args)),
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
name: 'rename_idea',
|
|
1680
|
+
description: "Rename an Idea.",
|
|
1681
|
+
inputSchema: renameIdeaInput,
|
|
1682
|
+
handler: async (client, args) => renameIdea(client, renameIdeaInput.parse(args)),
|
|
1683
|
+
},
|
|
1684
|
+
{
|
|
1685
|
+
name: 'recolor_idea',
|
|
1686
|
+
description: "Change an Idea's color (any CSS color string).",
|
|
1687
|
+
inputSchema: recolorIdeaInput,
|
|
1688
|
+
handler: async (client, args) => recolorIdea(client, recolorIdeaInput.parse(args)),
|
|
1689
|
+
},
|
|
1690
|
+
{
|
|
1691
|
+
name: 'set_idea_members',
|
|
1692
|
+
description: "Replace an Idea's full membership with `nodeIds`. Reconciles the junction table: inserts missing pairs, deletes pairs no longer in the set. Returns added / removed counts.",
|
|
1693
|
+
inputSchema: setIdeaMembersInput,
|
|
1694
|
+
handler: async (client, args) => setIdeaMembers(client, setIdeaMembersInput.parse(args)),
|
|
1695
|
+
},
|
|
1696
|
+
{
|
|
1697
|
+
name: 'add_to_idea',
|
|
1698
|
+
description: 'Add a single concept to an existing Idea (idempotent).',
|
|
1699
|
+
inputSchema: addToIdeaInput,
|
|
1700
|
+
handler: async (client, args) => addToIdea(client, addToIdeaInput.parse(args)),
|
|
1701
|
+
},
|
|
1702
|
+
{
|
|
1703
|
+
name: 'delete_idea',
|
|
1704
|
+
description: 'Delete an Idea. Membership rows cascade via FK; the underlying concepts are untouched.',
|
|
1705
|
+
inputSchema: deleteIdeaInput,
|
|
1706
|
+
handler: async (client, args) => deleteIdea(client, deleteIdeaInput.parse(args)),
|
|
1707
|
+
},
|
|
1708
|
+
// Projects
|
|
1709
|
+
{
|
|
1710
|
+
name: 'create_project',
|
|
1711
|
+
description: 'Create a new Project (problem-with-direction) with a name and brief. Optional `direction`.',
|
|
1712
|
+
inputSchema: createProjectInput,
|
|
1713
|
+
handler: async (client, args) => createProject(client, createProjectInput.parse(args)),
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
name: 'update_project',
|
|
1717
|
+
description: "Update a project's name / brief / direction / lifecycle. Only fields provided are changed.",
|
|
1718
|
+
inputSchema: updateProjectInput,
|
|
1719
|
+
handler: async (client, args) => updateProject(client, updateProjectInput.parse(args)),
|
|
1720
|
+
},
|
|
1721
|
+
{
|
|
1722
|
+
name: 'delete_project',
|
|
1723
|
+
description: "Delete a project. Project_nodes / problem_frames / closed_worlds / lab_matrix_cells cascade via FK. Concepts that belonged to ONLY this project are orphaned (project_id → NULL) — they are NOT auto-deleted.",
|
|
1724
|
+
inputSchema: deleteProjectInput,
|
|
1725
|
+
handler: async (client, args) => deleteProject(client, deleteProjectInput.parse(args)),
|
|
1726
|
+
},
|
|
1727
|
+
];
|