@feelingmindful/thinking-graph 1.4.0 → 1.5.0

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/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { relateSchema, relateHandler } from './tools/relate.js';
9
9
  import { recallSchema, recallHandler } from './tools/recall.js';
10
10
  import { learnSchema, learnHandler } from './tools/learn.js';
11
11
  import { exportSchema, exportHandler } from './tools/export.js';
12
+ import { researchSchema, researchHandler } from './tools/research.js';
12
13
  // Legacy compat shim removed — use `think` tool directly
13
14
  // ─── Storage setup ───────────────────────────────────────
14
15
  const memoryOnly = process.env.THINKING_GRAPH_MEMORY_ONLY === 'true';
@@ -30,6 +31,7 @@ server.tool('relate', 'Create a typed, directional relationship between two node
30
31
  server.tool('recall', 'Query the thinking graph — search by text, filter by type, traverse relationships, or search across projects.', recallSchema.shape, async (input) => recallHandler(graph, input));
31
32
  server.tool('learn', 'Store durable knowledge — code facts, tech debt, insights, principles. Deduplicates similar content.', learnSchema.shape, async (input) => learnHandler(graph, input));
32
33
  server.tool('export', 'Export the thinking graph as JSON or a human-readable markdown summary.', exportSchema.shape, async (input) => exportHandler(graph, input));
34
+ server.tool('research', 'Research a topic using Perplexity/Firecrawl, then ingest findings into the graph. Two-phase: call once to get an action plan, then again with findings to store them.', researchSchema.shape, async (input) => researchHandler(graph, input));
33
35
  // ─── Startup ─────────────────────────────────────────────
34
36
  async function main() {
35
37
  await storage.initialize();
@@ -30,13 +30,13 @@ export declare const learnSchema: z.ZodObject<{
30
30
  projectId?: string | undefined;
31
31
  metadata?: Record<string, unknown> | undefined;
32
32
  severity?: "critical" | "high" | "medium" | "low" | undefined;
33
+ filePath?: string | undefined;
34
+ lineRange?: [number, number] | undefined;
33
35
  relates?: {
34
36
  type: "depends_on" | "contradicts" | "supports" | "refines" | "supersedes" | "similar_to" | "located_in" | "violates" | "addresses" | "detected_by" | "invoked_by";
35
37
  targetId: string;
36
38
  reasoning?: string | undefined;
37
39
  }[] | undefined;
38
- filePath?: string | undefined;
39
- lineRange?: [number, number] | undefined;
40
40
  effort?: string | undefined;
41
41
  impact?: string | undefined;
42
42
  violatedBy?: string[] | undefined;
@@ -46,13 +46,13 @@ export declare const learnSchema: z.ZodObject<{
46
46
  projectId?: string | undefined;
47
47
  metadata?: Record<string, unknown> | undefined;
48
48
  severity?: "critical" | "high" | "medium" | "low" | undefined;
49
+ filePath?: string | undefined;
50
+ lineRange?: [number, number] | undefined;
49
51
  relates?: {
50
52
  type: "depends_on" | "contradicts" | "supports" | "refines" | "supersedes" | "similar_to" | "located_in" | "violates" | "addresses" | "detected_by" | "invoked_by";
51
53
  targetId: string;
52
54
  reasoning?: string | undefined;
53
55
  }[] | undefined;
54
- filePath?: string | undefined;
55
- lineRange?: [number, number] | undefined;
56
56
  effort?: string | undefined;
57
57
  impact?: string | undefined;
58
58
  violatedBy?: string[] | undefined;
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import type { ThinkingGraph } from '../engine/graph.js';
3
+ export declare const researchSchema: z.ZodObject<{
4
+ query: z.ZodString;
5
+ intent: z.ZodDefault<z.ZodEnum<["fact_check", "explore", "compare", "how_to", "current_state"]>>;
6
+ context: z.ZodOptional<z.ZodString>;
7
+ researchId: z.ZodOptional<z.ZodString>;
8
+ findings: z.ZodOptional<z.ZodArray<z.ZodObject<{
9
+ content: z.ZodString;
10
+ source: z.ZodOptional<z.ZodString>;
11
+ confidence: z.ZodOptional<z.ZodEnum<["high", "medium", "low"]>>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ content: string;
14
+ source?: string | undefined;
15
+ confidence?: "high" | "medium" | "low" | undefined;
16
+ }, {
17
+ content: string;
18
+ source?: string | undefined;
19
+ confidence?: "high" | "medium" | "low" | undefined;
20
+ }>, "many">>;
21
+ projectId: z.ZodOptional<z.ZodString>;
22
+ scrapeUrls: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
23
+ recencyFilter: z.ZodOptional<z.ZodEnum<["hour", "day", "week", "month", "year"]>>;
24
+ domainFilter: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
25
+ }, "strip", z.ZodTypeAny, {
26
+ query: string;
27
+ intent: "fact_check" | "explore" | "compare" | "how_to" | "current_state";
28
+ projectId?: string | undefined;
29
+ context?: string | undefined;
30
+ researchId?: string | undefined;
31
+ findings?: {
32
+ content: string;
33
+ source?: string | undefined;
34
+ confidence?: "high" | "medium" | "low" | undefined;
35
+ }[] | undefined;
36
+ scrapeUrls?: string[] | undefined;
37
+ recencyFilter?: "hour" | "day" | "week" | "month" | "year" | undefined;
38
+ domainFilter?: string[] | undefined;
39
+ }, {
40
+ query: string;
41
+ projectId?: string | undefined;
42
+ intent?: "fact_check" | "explore" | "compare" | "how_to" | "current_state" | undefined;
43
+ context?: string | undefined;
44
+ researchId?: string | undefined;
45
+ findings?: {
46
+ content: string;
47
+ source?: string | undefined;
48
+ confidence?: "high" | "medium" | "low" | undefined;
49
+ }[] | undefined;
50
+ scrapeUrls?: string[] | undefined;
51
+ recencyFilter?: "hour" | "day" | "week" | "month" | "year" | undefined;
52
+ domainFilter?: string[] | undefined;
53
+ }>;
54
+ export type ResearchInput = z.infer<typeof researchSchema>;
55
+ export declare function researchHandler(graph: ThinkingGraph, input: ResearchInput): Promise<{
56
+ content: {
57
+ type: "text";
58
+ text: string;
59
+ }[];
60
+ }>;
@@ -0,0 +1,236 @@
1
+ import { z } from 'zod';
2
+ const coerceBool = z.preprocess((v) => (v === 'true' ? true : v === 'false' ? false : v), z.boolean());
3
+ const RESEARCH_INTENTS = [
4
+ 'fact_check', // Verify a claim or assumption
5
+ 'explore', // Open-ended exploration of a topic
6
+ 'compare', // Compare options, libraries, approaches
7
+ 'how_to', // Find implementation guidance
8
+ 'current_state', // Get current state of something (version, status, etc.)
9
+ ];
10
+ const findingSchema = z.object({
11
+ content: z.string().describe('What was found'),
12
+ source: z.string().optional().describe('URL or citation'),
13
+ confidence: z.enum(['high', 'medium', 'low']).optional(),
14
+ });
15
+ export const researchSchema = z.object({
16
+ // Phase 1: initiate research
17
+ query: z.string().describe('What to research'),
18
+ intent: z.enum(RESEARCH_INTENTS).default('explore').describe('Research intent — guides tool selection'),
19
+ context: z.string().optional().describe('Why this research matters to the current task'),
20
+ // Phase 2: ingest findings (provide researchId from phase 1)
21
+ researchId: z.string().optional().describe('Node ID from phase 1 — triggers ingestion mode'),
22
+ findings: z.array(findingSchema).optional().describe('Results to store (phase 2)'),
23
+ // Options
24
+ projectId: z.string().optional(),
25
+ scrapeUrls: z.array(z.string()).optional().describe('Specific URLs to scrape with Firecrawl'),
26
+ recencyFilter: z.enum(['hour', 'day', 'week', 'month', 'year']).optional().describe('How recent results should be'),
27
+ domainFilter: z.array(z.string()).optional().describe('Restrict to these domains'),
28
+ });
29
+ function buildActionPlan(input) {
30
+ const steps = [];
31
+ const query = input.query;
32
+ // Choose Perplexity tool based on intent
33
+ switch (input.intent) {
34
+ case 'fact_check':
35
+ steps.push({
36
+ tool: 'mcp__perplexity__perplexity_ask',
37
+ description: 'Quick fact check with citations',
38
+ args: {
39
+ messages: [{ role: 'user', content: query }],
40
+ search_context_size: 'medium',
41
+ ...(input.recencyFilter && { search_recency_filter: input.recencyFilter }),
42
+ ...(input.domainFilter && { search_domain_filter: input.domainFilter }),
43
+ },
44
+ });
45
+ break;
46
+ case 'compare':
47
+ steps.push({
48
+ tool: 'mcp__perplexity__perplexity_reason',
49
+ description: 'Step-by-step comparison with web grounding',
50
+ args: {
51
+ messages: [{ role: 'user', content: query }],
52
+ search_context_size: 'high',
53
+ ...(input.recencyFilter && { search_recency_filter: input.recencyFilter }),
54
+ ...(input.domainFilter && { search_domain_filter: input.domainFilter }),
55
+ },
56
+ });
57
+ break;
58
+ case 'explore':
59
+ steps.push({
60
+ tool: 'mcp__perplexity__perplexity_research',
61
+ description: 'Deep multi-source research (30s+)',
62
+ args: {
63
+ messages: [{ role: 'user', content: query }],
64
+ reasoning_effort: 'medium',
65
+ },
66
+ });
67
+ break;
68
+ case 'how_to':
69
+ steps.push({
70
+ tool: 'mcp__perplexity__perplexity_ask',
71
+ description: 'Find implementation guidance',
72
+ args: {
73
+ messages: [{ role: 'user', content: query }],
74
+ search_context_size: 'high',
75
+ ...(input.recencyFilter && { search_recency_filter: input.recencyFilter }),
76
+ ...(input.domainFilter && { search_domain_filter: input.domainFilter }),
77
+ },
78
+ });
79
+ break;
80
+ case 'current_state':
81
+ steps.push({
82
+ tool: 'mcp__perplexity__perplexity_ask',
83
+ description: 'Get current state with recency filter',
84
+ args: {
85
+ messages: [{ role: 'user', content: query }],
86
+ search_recency_filter: input.recencyFilter ?? 'week',
87
+ search_context_size: 'medium',
88
+ ...(input.domainFilter && { search_domain_filter: input.domainFilter }),
89
+ },
90
+ });
91
+ break;
92
+ }
93
+ // If specific URLs provided, add Firecrawl scrape steps
94
+ if (input.scrapeUrls?.length) {
95
+ for (const url of input.scrapeUrls) {
96
+ steps.push({
97
+ tool: 'mcp__firecrawl__firecrawl_scrape',
98
+ description: `Scrape ${url}`,
99
+ args: {
100
+ url,
101
+ formats: ['markdown'],
102
+ onlyMainContent: true,
103
+ },
104
+ });
105
+ }
106
+ }
107
+ // For explore/compare, also suggest a Firecrawl search for additional sources
108
+ if (input.intent === 'explore' || input.intent === 'compare') {
109
+ steps.push({
110
+ tool: 'mcp__firecrawl__firecrawl_search',
111
+ description: 'Search for additional sources',
112
+ args: {
113
+ query,
114
+ limit: 5,
115
+ sources: [{ type: 'web' }],
116
+ },
117
+ });
118
+ }
119
+ return steps;
120
+ }
121
+ export async function researchHandler(graph, input) {
122
+ // ── Phase 2: ingest findings ──────────────────────────
123
+ if (input.researchId && input.findings?.length) {
124
+ const researchNode = await graph.getNode(input.researchId);
125
+ if (!researchNode) {
126
+ return {
127
+ content: [{
128
+ type: 'text',
129
+ text: JSON.stringify({ error: `Research node ${input.researchId} not found` }),
130
+ }],
131
+ };
132
+ }
133
+ const session = await graph.getCurrentSession();
134
+ const storedNodes = [];
135
+ for (const finding of input.findings) {
136
+ // Check for duplicates
137
+ const existing = await graph.findSimilar(finding.content, 'research', input.projectId);
138
+ if (existing) {
139
+ storedNodes.push(existing.id);
140
+ continue;
141
+ }
142
+ const node = await graph.addNode({
143
+ type: 'research',
144
+ content: finding.content,
145
+ sessionId: session.id,
146
+ projectId: input.projectId,
147
+ metadata: {
148
+ source: finding.source,
149
+ confidence: finding.confidence,
150
+ researchQuery: researchNode.content,
151
+ },
152
+ });
153
+ // Relate finding back to the research query node
154
+ await graph.addEdge({
155
+ sourceId: node.id,
156
+ targetId: input.researchId,
157
+ type: 'supports',
158
+ reasoning: 'Finding from research query',
159
+ });
160
+ storedNodes.push(node.id);
161
+ }
162
+ return {
163
+ content: [{
164
+ type: 'text',
165
+ text: JSON.stringify({
166
+ phase: 'ingest',
167
+ researchId: input.researchId,
168
+ storedCount: storedNodes.length,
169
+ nodeIds: storedNodes,
170
+ suggestions: [
171
+ {
172
+ tool: 'relate',
173
+ when: 'Connect these findings to the thoughts/decisions they inform',
174
+ example: {
175
+ sourceId: '<thought nodeId>',
176
+ targetId: storedNodes[0] ?? '<finding nodeId>',
177
+ type: 'depends_on',
178
+ reasoning: 'Decision informed by research',
179
+ },
180
+ },
181
+ {
182
+ tool: 'learn',
183
+ when: 'Promote a key finding to a durable insight that persists cross-project',
184
+ example: {
185
+ content: '<key takeaway>',
186
+ type: 'insight',
187
+ relates: [{ type: 'refines', targetId: input.researchId }],
188
+ },
189
+ },
190
+ ],
191
+ }),
192
+ }],
193
+ };
194
+ }
195
+ // ── Phase 1: initiate research ────────────────────────
196
+ const session = await graph.getCurrentSession();
197
+ const node = await graph.addNode({
198
+ type: 'research',
199
+ content: input.query,
200
+ sessionId: session.id,
201
+ projectId: input.projectId,
202
+ metadata: {
203
+ intent: input.intent,
204
+ context: input.context,
205
+ status: 'pending',
206
+ },
207
+ });
208
+ const actionPlan = buildActionPlan(input);
209
+ return {
210
+ content: [{
211
+ type: 'text',
212
+ text: JSON.stringify({
213
+ phase: 'initiate',
214
+ researchId: node.id,
215
+ query: input.query,
216
+ intent: input.intent,
217
+ actionPlan,
218
+ ingestStep: {
219
+ tool: 'research',
220
+ description: 'After executing the action plan, pipe findings back here',
221
+ args: {
222
+ query: input.query,
223
+ researchId: node.id,
224
+ findings: [
225
+ {
226
+ content: '<summarize what you found>',
227
+ source: '<url or citation>',
228
+ confidence: 'high',
229
+ },
230
+ ],
231
+ },
232
+ },
233
+ }),
234
+ }],
235
+ };
236
+ }
@@ -1,6 +1,79 @@
1
1
  import { z } from 'zod';
2
- import { NODE_TYPES, EDGE_TYPES } from '../engine/types.js';
2
+ import { NODE_TYPES, EDGE_TYPES, GLOBAL_NODE_TYPES } from '../engine/types.js';
3
3
  const coerceBool = z.preprocess((v) => (v === 'true' ? true : v === 'false' ? false : v), z.boolean());
4
+ function buildSuggestions(type, thoughtNumber, relatedCount, stats) {
5
+ const suggestions = [];
6
+ // First thought in session — suggest recall to check prior knowledge
7
+ if (thoughtNumber === 1 && stats.totalNodes > 1) {
8
+ suggestions.push({
9
+ tool: 'recall',
10
+ when: 'Check what the graph already knows before reasoning from scratch',
11
+ example: { query: '<topic>', crossProject: true },
12
+ });
13
+ }
14
+ // Decision nodes with no relationships — suggest relate
15
+ if (type === 'decision' && relatedCount === 0) {
16
+ suggestions.push({
17
+ tool: 'relate',
18
+ when: 'Connect this decision to what it depends on or what it supersedes',
19
+ example: { sourceId: '<this nodeId>', targetId: '?<search term>', type: 'depends_on' },
20
+ });
21
+ }
22
+ // Assumption nodes — suggest relate and research to verify
23
+ if (type === 'assumption') {
24
+ suggestions.push({
25
+ tool: 'relate',
26
+ when: 'Link this assumption to the decision or design it supports',
27
+ example: { sourceId: '<this nodeId>', targetId: '?<decision>', type: 'supports' },
28
+ });
29
+ suggestions.push({
30
+ tool: 'research',
31
+ when: 'Verify this assumption with external sources before building on it',
32
+ example: { query: '<the assumption to verify>', intent: 'fact_check' },
33
+ });
34
+ }
35
+ // Detection or tech_debt — suggest relate to violated principle
36
+ if (type === 'detection' || type === 'tech_debt') {
37
+ suggestions.push({
38
+ tool: 'relate',
39
+ when: 'Link to the principle this violates (pre-seeded: SRP, DRY, YAGNI, etc.)',
40
+ example: { sourceId: '<this nodeId>', targetId: '?<principle name>', type: 'violates' },
41
+ });
42
+ }
43
+ // Insight, pattern, or principle — suggest learn to persist cross-project
44
+ if (GLOBAL_NODE_TYPES.includes(type)) {
45
+ suggestions.push({
46
+ tool: 'learn',
47
+ when: 'Persist this so it carries across sessions and projects',
48
+ example: { content: '<the insight>', type },
49
+ });
50
+ }
51
+ // Code fact — suggest learn with file metadata
52
+ if (type === 'code_fact') {
53
+ suggestions.push({
54
+ tool: 'learn',
55
+ when: 'Store this code fact with its file location for future recall',
56
+ example: { content: '<fact>', type: 'code_fact', filePath: '<path>', lineRange: [0, 0] },
57
+ });
58
+ }
59
+ // Research node via think — redirect to the full research tool
60
+ if (type === 'research') {
61
+ suggestions.push({
62
+ tool: 'research',
63
+ when: 'Use the research tool instead — it generates an action plan with Perplexity/Firecrawl calls and handles ingestion',
64
+ example: { query: '<what to research>', intent: 'explore' },
65
+ });
66
+ }
67
+ // Generic: if the node has no relationships and there are other nodes, nudge relate
68
+ if (relatedCount === 0 && stats.totalNodes > 2 && type === 'thought') {
69
+ suggestions.push({
70
+ tool: 'relate',
71
+ when: 'Connect this thought to related nodes (use "?<search>" to find by content)',
72
+ example: { sourceId: '<this nodeId>', targetId: '?<search term>', type: 'supports' },
73
+ });
74
+ }
75
+ return suggestions;
76
+ }
4
77
  export const thinkSchema = z.object({
5
78
  thought: z.string().describe('The reasoning content'),
6
79
  type: z.enum(NODE_TYPES).default('thought').describe('Node type'),
@@ -53,6 +126,7 @@ export async function thinkHandler(graph, input) {
53
126
  }
54
127
  }
55
128
  const stats = await graph.storage.getStats();
129
+ const suggestions = buildSuggestions(input.type ?? 'thought', input.thoughtNumber, relatedCount, stats);
56
130
  return {
57
131
  content: [{
58
132
  type: 'text',
@@ -64,6 +138,7 @@ export async function thinkHandler(graph, input) {
64
138
  branches: [],
65
139
  thoughtHistoryLength: stats.totalNodes,
66
140
  relatedNodes: relatedCount,
141
+ ...(suggestions.length > 0 && { suggestions }),
67
142
  }),
68
143
  }],
69
144
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feelingmindful/thinking-graph",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Persistent graph-based MCP thinking server for the feeling-mindful plugin marketplace",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",