@gotza02/sequential-thinking 2026.3.0 → 2026.3.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/dist/graph.d.ts CHANGED
@@ -65,5 +65,5 @@ export declare class ProjectKnowledgeGraph {
65
65
  referencedBy: number;
66
66
  }[];
67
67
  };
68
- toMermaid(): string;
68
+ toMermaid(type?: 'flowchart' | 'classDiagram'): string;
69
69
  }
package/dist/graph.js CHANGED
@@ -833,7 +833,59 @@ export class ProjectKnowledgeGraph {
833
833
  }))
834
834
  };
835
835
  }
836
- toMermaid() {
836
+ toMermaid(type = 'flowchart') {
837
+ if (type === 'classDiagram') {
838
+ const lines = ['classDiagram'];
839
+ const fileToClasses = new Map();
840
+ for (const [filePath, node] of this.nodes) {
841
+ const classesInFile = [];
842
+ // Parse symbols to find classes/interfaces
843
+ for (const symbol of node.symbols) {
844
+ const parts = symbol.split(':');
845
+ if (parts.length < 2)
846
+ continue; // Skip malformed symbols
847
+ const kind = parts[0];
848
+ const name = parts[1];
849
+ if (['class', 'interface', 'type', 'enum'].includes(kind)) {
850
+ lines.push(` class ${name} {`);
851
+ lines.push(` <<${kind}>>`);
852
+ lines.push(` }`);
853
+ classesInFile.push(name);
854
+ }
855
+ }
856
+ if (classesInFile.length > 0) {
857
+ fileToClasses.set(filePath, classesInFile);
858
+ }
859
+ }
860
+ // Add relationships based on imports
861
+ // This is tricky because imports are file-based, not class-based.
862
+ // We'll link all classes in File A to all classes in File B if A imports B.
863
+ // To reduce noise, we limit this to explicit class dependencies if possible,
864
+ // but our graph only knows file dependencies.
865
+ // We'll stick to a simplified view: Classes in A depend on Classes in B.
866
+ for (const [filePath, node] of this.nodes) {
867
+ const sources = fileToClasses.get(filePath);
868
+ if (!sources)
869
+ continue;
870
+ for (const importPath of node.imports) {
871
+ const targets = fileToClasses.get(importPath);
872
+ if (targets) {
873
+ for (const s of sources) {
874
+ for (const t of targets) {
875
+ // Avoid self-reference loops for simplicity in visualization
876
+ if (s !== t) {
877
+ lines.push(` ${s} ..> ${t}`);
878
+ }
879
+ }
880
+ }
881
+ }
882
+ }
883
+ }
884
+ if (lines.length === 1)
885
+ return 'classDiagram\n note "No classes/interfaces found to visualize"';
886
+ return lines.join('\n');
887
+ }
888
+ // Default: Flowchart (File Dependency Graph)
837
889
  const lines = ['graph TD'];
838
890
  const fileToId = new Map();
839
891
  let idCounter = 0;
package/dist/index.js CHANGED
@@ -7,12 +7,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  import { SequentialThinkingServer } from './lib.js';
8
8
  import { ProjectKnowledgeGraph } from './graph.js';
9
9
  import { NotesManager } from './notes.js';
10
+ import { KnowledgeGraphManager } from './knowledge.js';
10
11
  import { CodeDatabase } from './codestore.js';
11
12
  import { registerThinkingTools } from './tools/thinking.js';
12
13
  import { registerWebTools } from './tools/web.js';
13
14
  import { registerFileSystemTools } from './tools/filesystem.js';
14
15
  import { registerGraphTools } from './tools/graph.js';
15
16
  import { registerNoteTools } from './tools/notes.js';
17
+ import { registerKnowledgeTools } from './tools/knowledge.js';
16
18
  import { registerCodingTools } from './tools/coding.js';
17
19
  import { registerCodeDbTools } from './tools/codestore.js';
18
20
  import { registerHumanTools } from './tools/human.js';
@@ -26,6 +28,7 @@ const server = new McpServer({
26
28
  });
27
29
  const thinkingServer = new SequentialThinkingServer(process.env.THOUGHTS_STORAGE_PATH || 'thoughts_history.json', parseInt(process.env.THOUGHT_DELAY_MS || '0', 10));
28
30
  const knowledgeGraph = new ProjectKnowledgeGraph();
31
+ const memoryGraph = new KnowledgeGraphManager(process.env.MEMORY_GRAPH_PATH || 'knowledge_graph.json');
29
32
  const notesManager = new NotesManager(process.env.NOTES_STORAGE_PATH || 'project_notes.json');
30
33
  const codeDb = new CodeDatabase(process.env.CODE_DB_PATH || 'code_database.json');
31
34
  // Register tools
@@ -34,6 +37,7 @@ registerWebTools(server);
34
37
  registerFileSystemTools(server);
35
38
  registerGraphTools(server, knowledgeGraph);
36
39
  registerNoteTools(server, notesManager);
40
+ registerKnowledgeTools(server, memoryGraph);
37
41
  registerCodingTools(server, knowledgeGraph);
38
42
  registerCodeDbTools(server, codeDb);
39
43
  registerHumanTools(server);
@@ -0,0 +1,50 @@
1
+ export interface KnowledgeNode {
2
+ id: string;
3
+ label: string;
4
+ name: string;
5
+ properties: Record<string, any>;
6
+ }
7
+ export interface KnowledgeEdge {
8
+ source: string;
9
+ target: string;
10
+ relation: string;
11
+ weight?: number;
12
+ properties?: Record<string, any>;
13
+ }
14
+ export interface GraphQuery {
15
+ startNodeId?: string;
16
+ startNodeName?: string;
17
+ relationType?: string;
18
+ maxDepth?: number;
19
+ }
20
+ export declare class KnowledgeGraphManager {
21
+ private filePath;
22
+ private nodes;
23
+ private edges;
24
+ private lastModifiedTime;
25
+ private mutex;
26
+ constructor(storagePath?: string);
27
+ private load;
28
+ private save;
29
+ /**
30
+ * Add a new entity (node) to the graph.
31
+ * Updates existing node if ID or Name matches (simple dedup).
32
+ */
33
+ addNode(node: Omit<KnowledgeNode, 'id'> & {
34
+ id?: string;
35
+ }): Promise<KnowledgeNode>;
36
+ /**
37
+ * Create a relationship (edge) between two nodes.
38
+ */
39
+ addEdge(edge: KnowledgeEdge): Promise<KnowledgeEdge>;
40
+ /**
41
+ * Query the graph to find connected nodes.
42
+ * Simple BFS traversal up to maxDepth.
43
+ */
44
+ query(params: GraphQuery): Promise<{
45
+ nodes: KnowledgeNode[];
46
+ edges: KnowledgeEdge[];
47
+ }>;
48
+ deleteNode(id: string): Promise<boolean>;
49
+ searchNodes(query: string): Promise<KnowledgeNode[]>;
50
+ }
@@ -0,0 +1,222 @@
1
+ import * as fs from 'fs/promises';
2
+ import { existsSync, statSync } from 'fs';
3
+ import * as path from 'path';
4
+ import { AsyncMutex } from './utils.js';
5
+ export class KnowledgeGraphManager {
6
+ filePath;
7
+ nodes = new Map();
8
+ edges = [];
9
+ lastModifiedTime = 0;
10
+ mutex = new AsyncMutex();
11
+ constructor(storagePath = 'knowledge_graph.json') {
12
+ this.filePath = path.resolve(storagePath);
13
+ }
14
+ async load(forceReload = false) {
15
+ try {
16
+ if (!existsSync(this.filePath)) {
17
+ this.nodes.clear();
18
+ this.edges = [];
19
+ this.lastModifiedTime = 0;
20
+ return;
21
+ }
22
+ const stats = statSync(this.filePath);
23
+ const currentMtime = stats.mtimeMs;
24
+ if (!forceReload && currentMtime === this.lastModifiedTime && this.nodes.size > 0) {
25
+ return;
26
+ }
27
+ const data = await fs.readFile(this.filePath, 'utf-8');
28
+ if (!data.trim()) {
29
+ this.nodes.clear();
30
+ this.edges = [];
31
+ return;
32
+ }
33
+ const parsed = JSON.parse(data);
34
+ this.nodes.clear();
35
+ if (parsed.nodes) {
36
+ parsed.nodes.forEach(n => this.nodes.set(n.id, n));
37
+ }
38
+ this.edges = parsed.edges || [];
39
+ this.lastModifiedTime = currentMtime;
40
+ }
41
+ catch (error) {
42
+ console.error(`[KnowledgeGraphManager] Load error:`, error);
43
+ // Fallback to empty on error
44
+ this.nodes.clear();
45
+ this.edges = [];
46
+ }
47
+ }
48
+ async save() {
49
+ const tmpPath = `${this.filePath}.tmp`;
50
+ const storage = {
51
+ version: '1.0',
52
+ nodes: Array.from(this.nodes.values()),
53
+ edges: this.edges,
54
+ metadata: {
55
+ lastModified: new Date().toISOString()
56
+ }
57
+ };
58
+ try {
59
+ await fs.writeFile(tmpPath, JSON.stringify(storage, null, 2), 'utf-8');
60
+ await fs.rename(tmpPath, this.filePath);
61
+ const stats = await fs.stat(this.filePath);
62
+ this.lastModifiedTime = stats.mtimeMs;
63
+ }
64
+ catch (error) {
65
+ try {
66
+ await fs.unlink(tmpPath);
67
+ }
68
+ catch { }
69
+ throw error;
70
+ }
71
+ }
72
+ /**
73
+ * Add a new entity (node) to the graph.
74
+ * Updates existing node if ID or Name matches (simple dedup).
75
+ */
76
+ async addNode(node) {
77
+ return this.mutex.dispatch(async () => {
78
+ await this.load();
79
+ // Check if node exists by ID
80
+ if (node.id && this.nodes.has(node.id)) {
81
+ const existing = this.nodes.get(node.id);
82
+ // Update properties
83
+ existing.properties = { ...existing.properties, ...node.properties };
84
+ // Update other fields if provided
85
+ if (node.label)
86
+ existing.label = node.label;
87
+ if (node.name)
88
+ existing.name = node.name;
89
+ await this.save();
90
+ return existing;
91
+ }
92
+ // Check if node exists by Name + Label (fuzzy match avoidance)
93
+ // This prevents creating duplicate "Liverpool" nodes
94
+ const existingByName = Array.from(this.nodes.values()).find(n => n.name.toLowerCase() === node.name.toLowerCase() &&
95
+ n.label.toLowerCase() === node.label.toLowerCase());
96
+ if (existingByName) {
97
+ existingByName.properties = { ...existingByName.properties, ...node.properties };
98
+ await this.save();
99
+ return existingByName;
100
+ }
101
+ const newNode = {
102
+ id: node.id || `node-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
103
+ label: node.label,
104
+ name: node.name,
105
+ properties: node.properties || {}
106
+ };
107
+ this.nodes.set(newNode.id, newNode);
108
+ await this.save();
109
+ return newNode;
110
+ });
111
+ }
112
+ /**
113
+ * Create a relationship (edge) between two nodes.
114
+ */
115
+ async addEdge(edge) {
116
+ return this.mutex.dispatch(async () => {
117
+ await this.load();
118
+ // Validate nodes exist
119
+ if (!this.nodes.has(edge.source))
120
+ throw new Error(`Source node ${edge.source} not found`);
121
+ if (!this.nodes.has(edge.target))
122
+ throw new Error(`Target node ${edge.target} not found`);
123
+ // Check for existing edge
124
+ const existingIndex = this.edges.findIndex(e => e.source === edge.source &&
125
+ e.target === edge.target &&
126
+ e.relation === edge.relation);
127
+ if (existingIndex >= 0) {
128
+ // Update existing edge
129
+ this.edges[existingIndex] = {
130
+ ...this.edges[existingIndex],
131
+ ...edge,
132
+ properties: { ...this.edges[existingIndex].properties, ...edge.properties }
133
+ };
134
+ await this.save();
135
+ return this.edges[existingIndex];
136
+ }
137
+ this.edges.push(edge);
138
+ await this.save();
139
+ return edge;
140
+ });
141
+ }
142
+ /**
143
+ * Query the graph to find connected nodes.
144
+ * Simple BFS traversal up to maxDepth.
145
+ */
146
+ async query(params) {
147
+ return this.mutex.dispatch(async () => {
148
+ await this.load();
149
+ let startNodeId = params.startNodeId;
150
+ // Resolve name to ID if needed
151
+ if (!startNodeId && params.startNodeName) {
152
+ const node = Array.from(this.nodes.values()).find(n => n.name.toLowerCase().includes(params.startNodeName.toLowerCase()));
153
+ if (node)
154
+ startNodeId = node.id;
155
+ }
156
+ if (!startNodeId) {
157
+ // If no start node, return everything (or maybe limit?)
158
+ // For now, let's return everything if specific query is missing, but maybe that's too much.
159
+ // Better: Return error or empty if no entry point.
160
+ if (!params.startNodeName)
161
+ return { nodes: Array.from(this.nodes.values()), edges: this.edges };
162
+ return { nodes: [], edges: [] };
163
+ }
164
+ const resultNodes = new Set();
165
+ const resultEdges = [];
166
+ const resultEdgesSet = new Set(); // To track unique edges
167
+ const queue = [{ id: startNodeId, depth: 0 }];
168
+ resultNodes.add(startNodeId);
169
+ const maxDepth = params.maxDepth || 1;
170
+ while (queue.length > 0) {
171
+ const current = queue.shift();
172
+ if (current.depth >= maxDepth)
173
+ continue;
174
+ // Find outgoing edges
175
+ const outgoing = this.edges.filter(e => e.source === current.id);
176
+ // Find incoming edges (optional, but good for context)
177
+ const incoming = this.edges.filter(e => e.target === current.id);
178
+ const relevantEdges = [...outgoing, ...incoming];
179
+ for (const edge of relevantEdges) {
180
+ if (params.relationType && edge.relation !== params.relationType)
181
+ continue;
182
+ // Create a unique key for the edge to prevent duplicates
183
+ const edgeKey = `${edge.source}-${edge.target}-${edge.relation}`;
184
+ if (!resultEdgesSet.has(edgeKey)) {
185
+ resultEdges.push(edge);
186
+ resultEdgesSet.add(edgeKey);
187
+ }
188
+ const neighborId = edge.source === current.id ? edge.target : edge.source;
189
+ if (!resultNodes.has(neighborId)) {
190
+ resultNodes.add(neighborId);
191
+ queue.push({ id: neighborId, depth: current.depth + 1 });
192
+ }
193
+ }
194
+ }
195
+ return {
196
+ nodes: Array.from(resultNodes).map(id => this.nodes.get(id)),
197
+ edges: resultEdges
198
+ };
199
+ });
200
+ }
201
+ async deleteNode(id) {
202
+ return this.mutex.dispatch(async () => {
203
+ await this.load();
204
+ if (!this.nodes.has(id))
205
+ return false;
206
+ this.nodes.delete(id);
207
+ // Remove connected edges
208
+ this.edges = this.edges.filter(e => e.source !== id && e.target !== id);
209
+ await this.save();
210
+ return true;
211
+ });
212
+ }
213
+ async searchNodes(query) {
214
+ return this.mutex.dispatch(async () => {
215
+ await this.load();
216
+ const lowerQuery = query.toLowerCase();
217
+ return Array.from(this.nodes.values()).filter(n => n.name.toLowerCase().includes(lowerQuery) ||
218
+ n.label.toLowerCase().includes(lowerQuery) ||
219
+ JSON.stringify(n.properties).toLowerCase().includes(lowerQuery));
220
+ });
221
+ }
222
+ }
@@ -2,6 +2,9 @@ import { z } from "zod";
2
2
  import * as fs from 'fs/promises';
3
3
  import * as path from 'path';
4
4
  import { validatePath } from "../utils.js";
5
+ import { exec } from 'child_process';
6
+ import { promisify } from 'util';
7
+ const execAsync = promisify(exec);
5
8
  export function registerCodingTools(server, graph) {
6
9
  // 14. deep_code_analyze
7
10
  server.tool("deep_code_analyze", "Generates a 'Codebase Context Document' for a specific file or task. This tool learns from the codebase structure and symbols to provide deep insights before coding.", {
@@ -85,8 +88,9 @@ export function registerCodingTools(server, graph) {
85
88
  path: z.string().describe("Path to the file to edit"),
86
89
  oldText: z.string().describe("The exact text to replace"),
87
90
  newText: z.string().describe("The new code to insert"),
88
- reasoning: z.string().describe("The 'Deepest Thinking' reasoning behind this change")
89
- }, async ({ path: filePath, oldText, newText, reasoning }) => {
91
+ reasoning: z.string().describe("The 'Deepest Thinking' reasoning behind this change"),
92
+ runTest: z.string().optional().describe("Optional: Shell command to run tests (e.g., 'npm test'). If tests fail, the edit is automatically rolled back.")
93
+ }, async ({ path: filePath, oldText, newText, reasoning, runTest }) => {
90
94
  try {
91
95
  const absolutePath = validatePath(filePath);
92
96
  const content = await fs.readFile(absolutePath, 'utf-8');
@@ -115,6 +119,33 @@ export function registerCodingTools(server, graph) {
115
119
  }
116
120
  try {
117
121
  await fs.writeFile(absolutePath, newContent, 'utf-8');
122
+ // --- TDD: Run Test if requested ---
123
+ if (runTest) {
124
+ try {
125
+ // Execute the test command
126
+ const { stdout, stderr } = await execAsync(runTest, { cwd: process.cwd() });
127
+ // If we get here, exit code was 0 (success)
128
+ return {
129
+ content: [{
130
+ type: "text",
131
+ text: `Successfully applied edit to ${filePath} and verified with tests.\n\nReasoning: ${reasoning}\n\nTest Command: '${runTest}'\nTest Result: PASSED ✅\nStdout: ${stdout.trim().slice(0, 200)}...\n(Backup created at ${backupPath})`
132
+ }]
133
+ };
134
+ }
135
+ catch (testError) {
136
+ // Test failed (non-zero exit code)
137
+ console.error(`Test failed for ${filePath}:`, testError);
138
+ // Rollback
139
+ await fs.copyFile(backupPath, absolutePath);
140
+ return {
141
+ content: [{
142
+ type: "text",
143
+ text: `⛔ TDD SAFETY ROLLBACK TRIGGERED ⛔\n\nThe edit was applied but the test command '${runTest}' failed.\nChanges have been automatically reverted to the original state.\n\nError Output:\n${testError.stdout || testError.message}\n${testError.stderr || ''}`
144
+ }],
145
+ isError: true
146
+ };
147
+ }
148
+ }
118
149
  }
119
150
  catch (writeError) {
120
151
  // Rollback attempt
@@ -76,12 +76,14 @@ export function registerGraphTools(server, knowledgeGraph) {
76
76
  }
77
77
  });
78
78
  // 14. get_project_graph_visualization
79
- server.tool("get_project_graph_visualization", "Get a Mermaid Diagram string representing the project's dependency graph. You can render this string in a Markdown viewer that supports Mermaid.", {}, async () => {
79
+ server.tool("get_project_graph_visualization", "Get a Mermaid Diagram string representing the project's dependency graph. You can render this string in a Markdown viewer that supports Mermaid.", {
80
+ type: z.enum(['flowchart', 'classDiagram']).optional().default('flowchart').describe("Type of diagram to generate (default: 'flowchart' for file dependencies, 'classDiagram' for class structure)")
81
+ }, async ({ type }) => {
80
82
  try {
81
- const diagram = knowledgeGraph.toMermaid();
82
- if (diagram.trim() === 'graph TD') {
83
+ const diagram = knowledgeGraph.toMermaid(type);
84
+ if (diagram.trim() === 'graph TD' || diagram.trim() === 'classDiagram') {
83
85
  return {
84
- content: [{ type: "text", text: "Graph is empty. Please run 'build_project_graph' first." }],
86
+ content: [{ type: "text", text: "Graph is empty or no compatible symbols found. Please run 'build_project_graph' first." }],
85
87
  isError: true
86
88
  };
87
89
  }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { KnowledgeGraphManager } from '../knowledge.js';
3
+ export declare function registerKnowledgeTools(server: McpServer, graphManager: KnowledgeGraphManager): void;
@@ -0,0 +1,106 @@
1
+ import { z } from "zod";
2
+ export function registerKnowledgeTools(server, graphManager) {
3
+ // 1. add_knowledge_entity
4
+ server.tool("add_knowledge_entity", "Create a new entity (Node) in the Knowledge Graph. Use this to store structured information about people, teams, concepts, etc.", {
5
+ label: z.string().describe("Type of entity (e.g., 'Player', 'Team', 'Tactic', 'Concept')"),
6
+ name: z.string().describe("Name of the entity (e.g., 'Trent Alexander-Arnold', 'Liverpool')"),
7
+ properties: z.record(z.string(), z.any()).optional().describe("Additional attributes (e.g., { status: 'Injured', position: 'RB' })"),
8
+ id: z.string().optional().describe("Optional: Specific ID. If omitted, one is generated.")
9
+ }, async ({ label, name, properties, id }) => {
10
+ try {
11
+ const node = await graphManager.addNode({ label, name, properties: properties || {}, id });
12
+ return {
13
+ content: [{
14
+ type: "text",
15
+ text: `Entity added/updated successfully.\nID: ${node.id}\nName: ${node.name} (${node.label})`
16
+ }]
17
+ };
18
+ }
19
+ catch (error) {
20
+ return { content: [{ type: "text", text: `Error adding entity: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
21
+ }
22
+ });
23
+ // 2. link_knowledge_nodes
24
+ server.tool("link_knowledge_nodes", "Create a relationship (Edge) between two entities in the Knowledge Graph. Use this to connect facts (e.g., Player -> plays for -> Team).", {
25
+ sourceId: z.string().describe("ID of the source node"),
26
+ targetId: z.string().describe("ID of the target node"),
27
+ relation: z.string().describe("Type of relationship (e.g., 'PLAYS_FOR', 'AFFECTS', 'IS_INJURED')"),
28
+ weight: z.number().optional().describe("Strength/Weight of connection (0.0 to 1.0)"),
29
+ properties: z.record(z.string(), z.any()).optional().describe("Edge attributes (e.g., { since: '2024' })")
30
+ }, async ({ sourceId, targetId, relation, weight, properties }) => {
31
+ try {
32
+ const edge = await graphManager.addEdge({
33
+ source: sourceId,
34
+ target: targetId,
35
+ relation,
36
+ weight,
37
+ properties
38
+ });
39
+ return {
40
+ content: [{
41
+ type: "text",
42
+ text: `Link created successfully: [${sourceId}] --(${relation})--> [${targetId}]`
43
+ }]
44
+ };
45
+ }
46
+ catch (error) {
47
+ return { content: [{ type: "text", text: `Error linking nodes: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
48
+ }
49
+ });
50
+ // 3. query_knowledge_graph
51
+ server.tool("query_knowledge_graph", "Search and traverse the Knowledge Graph to find connected facts. Use this to retrieve context for analysis.", {
52
+ query: z.string().optional().describe("Search term for node name/label (Start Node)"),
53
+ nodeId: z.string().optional().describe("Specific Node ID to start traversal from"),
54
+ relationType: z.string().optional().describe("Filter by relationship type"),
55
+ maxDepth: z.number().optional().default(1).describe("Traversal depth (default: 1)")
56
+ }, async ({ query, nodeId, relationType, maxDepth }) => {
57
+ try {
58
+ if (!query && !nodeId) {
59
+ // Fallback: list all nodes if no query (limit to top 20 to avoid spam)
60
+ const results = await graphManager.searchNodes("");
61
+ const preview = results.slice(0, 20);
62
+ return {
63
+ content: [{
64
+ type: "text",
65
+ text: `Graph Overview (${results.length} nodes total):\n${JSON.stringify(preview, null, 2)}`
66
+ }]
67
+ };
68
+ }
69
+ const result = await graphManager.query({
70
+ startNodeId: nodeId,
71
+ startNodeName: query,
72
+ relationType,
73
+ maxDepth
74
+ });
75
+ if (result.nodes.length === 0) {
76
+ return { content: [{ type: "text", text: "No matching knowledge found." }] };
77
+ }
78
+ return {
79
+ content: [{
80
+ type: "text",
81
+ text: JSON.stringify(result, null, 2)
82
+ }]
83
+ };
84
+ }
85
+ catch (error) {
86
+ return { content: [{ type: "text", text: `Error querying graph: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
87
+ }
88
+ });
89
+ // 4. delete_knowledge_entity
90
+ server.tool("delete_knowledge_entity", "Remove a node from the Knowledge Graph. Also removes connected edges.", {
91
+ id: z.string().describe("ID of the node to delete")
92
+ }, async ({ id }) => {
93
+ try {
94
+ const deleted = await graphManager.deleteNode(id);
95
+ return {
96
+ content: [{
97
+ type: "text",
98
+ text: deleted ? `Node ${id} deleted.` : `Node ${id} not found.`
99
+ }]
100
+ };
101
+ }
102
+ catch (error) {
103
+ return { content: [{ type: "text", text: `Error deleting node: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
104
+ }
105
+ });
106
+ }
@@ -0,0 +1,44 @@
1
+ export interface Prediction {
2
+ id: string;
3
+ date: string;
4
+ league: string;
5
+ homeTeam: string;
6
+ awayTeam: string;
7
+ selection: string;
8
+ odds: number;
9
+ stake: number;
10
+ confidence: number;
11
+ analysis: string;
12
+ status: 'pending' | 'won' | 'lost' | 'void';
13
+ resultScore?: string;
14
+ profit?: number;
15
+ createdAt: string;
16
+ updatedAt: string;
17
+ }
18
+ export interface PredictionStats {
19
+ totalBets: number;
20
+ wins: number;
21
+ losses: number;
22
+ voids: number;
23
+ totalStake: number;
24
+ totalProfit: number;
25
+ roi: number;
26
+ winRate: number;
27
+ strikeRate: number;
28
+ }
29
+ export declare class PredictionTracker {
30
+ private filePath;
31
+ private predictions;
32
+ private lastModifiedTime;
33
+ private mutex;
34
+ constructor(storagePath?: string);
35
+ private load;
36
+ private save;
37
+ track(bet: Omit<Prediction, 'id' | 'status' | 'resultScore' | 'profit' | 'createdAt' | 'updatedAt'>): Promise<Prediction>;
38
+ resolve(id: string, result: 'won' | 'lost' | 'void', score?: string): Promise<Prediction | null>;
39
+ getStats(filter?: {
40
+ league?: string;
41
+ lastDays?: number;
42
+ }): Promise<PredictionStats>;
43
+ list(status?: 'pending' | 'completed'): Promise<Prediction[]>;
44
+ }
@@ -0,0 +1,160 @@
1
+ import * as fs from 'fs/promises';
2
+ import { existsSync, statSync } from 'fs';
3
+ import * as path from 'path';
4
+ import { AsyncMutex } from '../../../utils.js';
5
+ export class PredictionTracker {
6
+ filePath;
7
+ predictions = [];
8
+ lastModifiedTime = 0;
9
+ mutex = new AsyncMutex();
10
+ constructor(storagePath = 'betting_log.json') {
11
+ this.filePath = path.resolve(storagePath);
12
+ }
13
+ async load(forceReload = false) {
14
+ try {
15
+ if (!existsSync(this.filePath)) {
16
+ this.predictions = [];
17
+ this.lastModifiedTime = 0;
18
+ return;
19
+ }
20
+ const stats = statSync(this.filePath);
21
+ const currentMtime = stats.mtimeMs;
22
+ if (!forceReload && currentMtime === this.lastModifiedTime && this.predictions.length > 0) {
23
+ return;
24
+ }
25
+ const data = await fs.readFile(this.filePath, 'utf-8');
26
+ if (!data.trim()) {
27
+ this.predictions = [];
28
+ return;
29
+ }
30
+ try {
31
+ const parsed = JSON.parse(data);
32
+ if (parsed.version && parsed.predictions) {
33
+ this.predictions = parsed.predictions;
34
+ }
35
+ else if (Array.isArray(parsed)) {
36
+ this.predictions = parsed;
37
+ }
38
+ else {
39
+ this.predictions = [];
40
+ }
41
+ this.lastModifiedTime = currentMtime;
42
+ }
43
+ catch (e) {
44
+ console.error('[PredictionTracker] Parse error:', e);
45
+ // Basic recovery: initialize empty
46
+ this.predictions = [];
47
+ }
48
+ }
49
+ catch (error) {
50
+ console.error('[PredictionTracker] Load error:', error);
51
+ this.predictions = [];
52
+ }
53
+ }
54
+ async save() {
55
+ const tmpPath = `${this.filePath}.tmp`;
56
+ const storage = {
57
+ version: '1.0',
58
+ predictions: this.predictions,
59
+ metadata: {
60
+ lastModified: new Date().toISOString()
61
+ }
62
+ };
63
+ try {
64
+ await fs.writeFile(tmpPath, JSON.stringify(storage, null, 2), 'utf-8');
65
+ await fs.rename(tmpPath, this.filePath);
66
+ const stats = await fs.stat(this.filePath);
67
+ this.lastModifiedTime = stats.mtimeMs;
68
+ }
69
+ catch (error) {
70
+ try {
71
+ await fs.unlink(tmpPath);
72
+ }
73
+ catch { }
74
+ throw error;
75
+ }
76
+ }
77
+ async track(bet) {
78
+ return this.mutex.dispatch(async () => {
79
+ await this.load();
80
+ const now = new Date().toISOString();
81
+ const prediction = {
82
+ id: `bet-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
83
+ ...bet,
84
+ status: 'pending',
85
+ createdAt: now,
86
+ updatedAt: now
87
+ };
88
+ this.predictions.push(prediction);
89
+ await this.save();
90
+ return prediction;
91
+ });
92
+ }
93
+ async resolve(id, result, score) {
94
+ return this.mutex.dispatch(async () => {
95
+ await this.load();
96
+ const index = this.predictions.findIndex(p => p.id === id);
97
+ if (index === -1)
98
+ return null;
99
+ const p = this.predictions[index];
100
+ p.status = result;
101
+ p.resultScore = score;
102
+ p.updatedAt = new Date().toISOString();
103
+ if (result === 'won') {
104
+ p.profit = (p.stake * p.odds) - p.stake;
105
+ }
106
+ else if (result === 'lost') {
107
+ p.profit = -p.stake;
108
+ }
109
+ else {
110
+ p.profit = 0;
111
+ }
112
+ await this.save();
113
+ return p;
114
+ });
115
+ }
116
+ async getStats(filter) {
117
+ return this.mutex.dispatch(async () => {
118
+ await this.load();
119
+ let bets = this.predictions;
120
+ if (filter?.league) {
121
+ bets = bets.filter(p => p.league.toLowerCase().includes(filter.league.toLowerCase()));
122
+ }
123
+ if (filter?.lastDays) {
124
+ const cutoff = new Date();
125
+ cutoff.setDate(cutoff.getDate() - filter.lastDays);
126
+ bets = bets.filter(p => new Date(p.date) >= cutoff);
127
+ }
128
+ // Only count resolved bets for win rate/roi
129
+ const resolved = bets.filter(p => p.status !== 'pending');
130
+ const wins = resolved.filter(p => p.status === 'won').length;
131
+ const losses = resolved.filter(p => p.status === 'lost').length;
132
+ const voids = resolved.filter(p => p.status === 'void').length;
133
+ const totalStake = resolved.reduce((sum, p) => sum + p.stake, 0);
134
+ const totalProfit = resolved.reduce((sum, p) => sum + (p.profit || 0), 0);
135
+ return {
136
+ totalBets: bets.length,
137
+ wins,
138
+ losses,
139
+ voids,
140
+ totalStake,
141
+ totalProfit,
142
+ roi: totalStake > 0 ? (totalProfit / totalStake) * 100 : 0,
143
+ winRate: resolved.length > 0 ? (wins / resolved.length) * 100 : 0,
144
+ strikeRate: (wins + losses) > 0 ? (wins / (wins + losses)) * 100 : 0
145
+ };
146
+ });
147
+ }
148
+ async list(status) {
149
+ return this.mutex.dispatch(async () => {
150
+ await this.load();
151
+ if (status === 'pending') {
152
+ return this.predictions.filter(p => p.status === 'pending');
153
+ }
154
+ if (status === 'completed') {
155
+ return this.predictions.filter(p => p.status !== 'pending');
156
+ }
157
+ return this.predictions;
158
+ });
159
+ }
160
+ }
@@ -8,6 +8,7 @@ import { getSearchProvider } from '../providers/search.js';
8
8
  import { getGlobalCache, CacheService } from '../core/cache.js';
9
9
  import { formatStandingsTable, formatComparisonResult } from '../utils/formatter.js';
10
10
  import { CACHE_CONFIG, LEAGUES } from '../core/constants.js';
11
+ import { generateRadarChartUrl } from '../utils/charts.js';
11
12
  // ============= Helper Functions =============
12
13
  /**
13
14
  * Get league ID from league name
@@ -29,6 +30,39 @@ function getLeagueId(leagueName) {
29
30
  }
30
31
  return undefined;
31
32
  }
33
+ /**
34
+ * Calculate normalized metrics (0-100) for radar chart
35
+ */
36
+ function calculateMetrics(team) {
37
+ // Default [Attack, Defense, Form, Position, WinRate]
38
+ if (!team.stats)
39
+ return [50, 50, 50, 50, 50];
40
+ const s = team.stats;
41
+ const mp = s.matchesPlayed || 1;
42
+ // Attack: Goals per game (max ~3.0)
43
+ // 3.0 GPG = 100, 0.0 GPG = 0
44
+ const attack = Math.min(((s.goalsFor || 0) / mp) / 3.0 * 100, 100);
45
+ // Defense: Goals against per game (max ~3.0). Inverted.
46
+ // 0.0 GA = 100, 3.0 GA = 0
47
+ const gaPerGame = (s.goalsAgainst || 0) / mp;
48
+ const defense = Math.max(0, 100 - (gaPerGame / 3.0 * 100));
49
+ // Form: Last 5 points. Max 15.
50
+ let form = 50;
51
+ if (s.form) {
52
+ // Parse form string e.g. "WWDLL" or array
53
+ // Force to string to avoid runtime errors if API returns unexpected type
54
+ const formStr = String(Array.isArray(s.form) ? s.form.join('') : s.form);
55
+ const wins = (formStr.match(/W/g) || []).length;
56
+ const draws = (formStr.match(/D/g) || []).length;
57
+ const points = (wins * 3) + draws;
58
+ form = (points / 15) * 100;
59
+ }
60
+ // Position: 1-20. 1st=100, 20th=5.
61
+ const position = s.position ? Math.max(0, (21 - s.position) / 20 * 100) : 50;
62
+ // Win Rate: Wins / Matches * 100
63
+ const winRate = (s.wins / mp) * 100;
64
+ return [attack, defense, form, position, winRate].map(n => Math.round(n));
65
+ }
32
66
  /**
33
67
  * Search for team by name using web search
34
68
  */
@@ -138,6 +172,9 @@ async function compareTeamsAnalysis(teamAName, teamBName) {
138
172
  else {
139
173
  summary += `The teams are relatively evenly matched in terms of league standing. `;
140
174
  }
175
+ // Generate Radar Chart
176
+ const chartUrl = generateRadarChartUrl({ name: teamA.name, data: calculateMetrics(teamA) }, { name: teamB.name, data: calculateMetrics(teamB) });
177
+ summary += `\n\n![Visual Comparison](${chartUrl})\n\n`;
141
178
  }
142
179
  else {
143
180
  summary += `Detailed statistics not available via API. `;
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerTrackerTools(server: McpServer): void;
@@ -0,0 +1,73 @@
1
+ import { z } from "zod";
2
+ import { PredictionTracker } from "../core/tracker.js";
3
+ const tracker = new PredictionTracker();
4
+ export function registerTrackerTools(server) {
5
+ // 1. Log a new prediction
6
+ server.tool("track_prediction", "Log a sports prediction/bet to the ROI Tracker system. Use this immediately after generating a prediction to verify performance later.", {
7
+ league: z.string().describe("League name"),
8
+ homeTeam: z.string().describe("Home team name"),
9
+ awayTeam: z.string().describe("Away team name"),
10
+ selection: z.string().describe("The specific bet/prediction (e.g., 'Arsenal Win', 'Over 2.5 Goals')"),
11
+ odds: z.number().describe("Decimal odds at time of prediction"),
12
+ stake: z.number().describe("Stake units (1-10 recommended based on confidence)"),
13
+ confidence: z.number().min(0).max(100).describe("Confidence score (0-100)"),
14
+ analysis: z.string().describe("Brief summary of why this bet was picked"),
15
+ date: z.string().optional().describe("Match date (ISO format). Defaults to now if omitted.")
16
+ }, async (args) => {
17
+ const result = await tracker.track({
18
+ ...args,
19
+ date: args.date || new Date().toISOString()
20
+ });
21
+ return {
22
+ content: [{ type: "text", text: `Prediction logged successfully.\nID: ${result.id}\nSelection: ${result.selection} @ ${result.odds}\nStatus: PENDING` }]
23
+ };
24
+ });
25
+ // 2. Resolve a prediction
26
+ server.tool("resolve_prediction", "Update the status of a pending prediction (Win/Loss/Void). Use this after verifying match results.", {
27
+ id: z.string().describe("The Prediction ID (from track_prediction)"),
28
+ result: z.enum(['won', 'lost', 'void']).describe("The outcome of the bet"),
29
+ score: z.string().optional().describe("Final match score (e.g., '2-1')")
30
+ }, async ({ id, result, score }) => {
31
+ const updated = await tracker.resolve(id, result, score);
32
+ if (!updated) {
33
+ return { content: [{ type: "text", text: `Error: Prediction with ID '${id}' not found.` }], isError: true };
34
+ }
35
+ const profitStr = updated.profit > 0 ? `+${updated.profit.toFixed(2)}` : `${updated.profit}`;
36
+ return {
37
+ content: [{ type: "text", text: `Prediction resolved: ${result.toUpperCase()}\nProfit: ${profitStr} units\nROI Status updated.` }]
38
+ };
39
+ });
40
+ // 3. Analyze Performance
41
+ server.tool("analyze_roi", "Analyze the AI's betting performance (ROI, Strike Rate, Profit) to identify strengths and weaknesses.", {
42
+ league: z.string().optional().describe("Filter by specific league"),
43
+ lastDays: z.number().optional().describe("Analyze only the last N days")
44
+ }, async ({ league, lastDays }) => {
45
+ const stats = await tracker.getStats({ league, lastDays });
46
+ const roiColor = stats.roi > 0 ? "🟢" : (stats.roi < 0 ? "🔴" : "⚪");
47
+ let report = `--- 📊 ROI PERFORMANCE REPORT ---\n`;
48
+ if (league)
49
+ report += `Filter: League='${league}'\n`;
50
+ if (lastDays)
51
+ report += `Filter: Last ${lastDays} days\n`;
52
+ report += `\nTotal Bets: ${stats.totalBets}`;
53
+ report += `\nWin Rate: ${stats.winRate.toFixed(1)}% (${stats.wins}W - ${stats.losses}L - ${stats.voids}V)`;
54
+ report += `\nTotal Stake: ${stats.totalStake.toFixed(1)}`;
55
+ report += `\nTotal Profit: ${stats.totalProfit > 0 ? '+' : ''}${stats.totalProfit.toFixed(2)}`;
56
+ report += `\nROI: ${roiColor} ${stats.roi.toFixed(2)}%`;
57
+ return {
58
+ content: [{ type: "text", text: report }]
59
+ };
60
+ });
61
+ // 4. List Pending Predictions
62
+ server.tool("get_pending_predictions", "Get a list of all unresolved predictions that need result verification.", {}, async () => {
63
+ const pending = await tracker.list('pending');
64
+ if (pending.length === 0) {
65
+ return { content: [{ type: "text", text: "No pending predictions." }] };
66
+ }
67
+ let list = "--- PENDING PREDICTIONS ---\n";
68
+ pending.forEach(p => {
69
+ list += `- [${p.id}] ${p.homeTeam} vs ${p.awayTeam}: ${p.selection} @ ${p.odds} (${new Date(p.date).toLocaleDateString()})\n`;
70
+ });
71
+ return { content: [{ type: "text", text: list }] };
72
+ });
73
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Chart Generation Utilities using QuickChart.io
3
+ */
4
+ export interface TeamMetrics {
5
+ name: string;
6
+ data: number[];
7
+ }
8
+ export declare function generateRadarChartUrl(teamA: TeamMetrics, teamB: TeamMetrics, labels?: string[]): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Chart Generation Utilities using QuickChart.io
3
+ */
4
+ export function generateRadarChartUrl(teamA, teamB, labels = ['Attack', 'Defense', 'Form', 'Position', 'Win Rate']) {
5
+ const chartConfig = {
6
+ type: 'radar',
7
+ data: {
8
+ labels: labels,
9
+ datasets: [
10
+ {
11
+ label: teamA.name,
12
+ data: teamA.data,
13
+ backgroundColor: 'rgba(255, 99, 132, 0.2)',
14
+ borderColor: 'rgba(255, 99, 132, 1)',
15
+ pointBackgroundColor: 'rgba(255, 99, 132, 1)',
16
+ },
17
+ {
18
+ label: teamB.name,
19
+ data: teamB.data,
20
+ backgroundColor: 'rgba(54, 162, 235, 0.2)',
21
+ borderColor: 'rgba(54, 162, 235, 1)',
22
+ pointBackgroundColor: 'rgba(54, 162, 235, 1)',
23
+ }
24
+ ]
25
+ },
26
+ options: {
27
+ scale: {
28
+ ticks: {
29
+ beginAtZero: true,
30
+ min: 0,
31
+ max: 100
32
+ }
33
+ }
34
+ }
35
+ };
36
+ const json = JSON.stringify(chartConfig);
37
+ return `https://quickchart.io/chart?c=${encodeURIComponent(json)}`;
38
+ }
@@ -8,6 +8,7 @@ import { registerTeamTools } from "./sports/tools/team.js";
8
8
  import { registerPlayerTools } from "./sports/tools/player.js";
9
9
  import { registerBettingTools } from "./sports/tools/betting.js";
10
10
  import { registerLiveTools } from "./sports/tools/live.js";
11
+ import { registerTrackerTools } from "./sports/tools/tracker.js";
11
12
  // Legacy support - keep original analyze_football_match
12
13
  import { z } from "zod";
13
14
  import { fetchWithRetry } from "../utils.js";
@@ -22,6 +23,7 @@ export function registerSportsTools(server) {
22
23
  registerPlayerTools(server);
23
24
  registerBettingTools(server);
24
25
  registerLiveTools(server);
26
+ registerTrackerTools(server);
25
27
  // Keep original analyze_football_match for backward compatibility
26
28
  registerLegacyAnalyzeMatch(server);
27
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotza02/sequential-thinking",
3
- "version": "2026.3.0",
3
+ "version": "2026.3.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },