@feelingmindful/thinking-graph 1.8.1 → 1.10.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 +20 -3
- package/dist/tools/learn.d.ts +2 -1
- package/dist/tools/learn.js +26 -1
- package/dist/tools/recall.d.ts +4 -3
- package/dist/tools/recall.js +18 -5
- package/dist/tools/research.d.ts +2 -1
- package/dist/tools/research.js +27 -1
- package/dist/tools/think.js +7 -2
- package/dist/vault/bridge.d.ts +57 -0
- package/dist/vault/bridge.js +206 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
4
4
|
import { ThinkingGraph } from './engine/graph.js';
|
|
5
5
|
import { SQLiteAdapter } from './storage/sqlite.js';
|
|
6
6
|
import { InMemoryAdapter } from './storage/memory.js';
|
|
7
|
+
import { VaultBridge } from './vault/bridge.js';
|
|
7
8
|
import { thinkSchema, thinkHandler } from './tools/think.js';
|
|
8
9
|
import { relateSchema, relateHandler } from './tools/relate.js';
|
|
9
10
|
import { recallSchema, recallHandler } from './tools/recall.js';
|
|
@@ -19,6 +20,20 @@ const storage = memoryOnly
|
|
|
19
20
|
: new SQLiteAdapter({
|
|
20
21
|
dbPath: process.env.THINKING_GRAPH_PROJECT_DB || '.premium/thinking.db',
|
|
21
22
|
});
|
|
23
|
+
// ─── Vault bridge ────────────────────────────────────────
|
|
24
|
+
const vaultPath = process.env.THINKING_GRAPH_VAULT_PATH || '~/Documents/Obsidian/Dev';
|
|
25
|
+
const vault = new VaultBridge(vaultPath);
|
|
26
|
+
// Derive project slug from env or DB path (e.g. "feeling-mindful-plugins")
|
|
27
|
+
const projectSlug = process.env.THINKING_GRAPH_PROJECT_SLUG
|
|
28
|
+
|| deriveProjectSlug(process.env.THINKING_GRAPH_PROJECT_DB || '');
|
|
29
|
+
function deriveProjectSlug(dbPath) {
|
|
30
|
+
// Try CWD-based slug: last segment of the working directory
|
|
31
|
+
const cwd = process.cwd();
|
|
32
|
+
const segments = cwd.split('/').filter(Boolean);
|
|
33
|
+
if (segments.length > 0)
|
|
34
|
+
return segments[segments.length - 1];
|
|
35
|
+
return 'default';
|
|
36
|
+
}
|
|
22
37
|
// ─── Graph engine ────────────────────────────────────────
|
|
23
38
|
const graph = new ThinkingGraph(storage);
|
|
24
39
|
// ─── MCP Server ──────────────────────────────────────────
|
|
@@ -29,10 +44,10 @@ const server = new McpServer({
|
|
|
29
44
|
// Register new tools
|
|
30
45
|
server.tool('think', 'Record a reasoning step with optional typing, relationships, and metadata. Enhanced version of sequential thinking.', thinkSchema.shape, async (input) => thinkHandler(graph, input));
|
|
31
46
|
server.tool('relate', 'Create a typed, directional relationship between two nodes in the thinking graph.', relateSchema.shape, async (input) => relateHandler(graph, input));
|
|
32
|
-
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));
|
|
33
|
-
server.tool('learn', 'Store durable knowledge — code facts, tech debt, insights, principles.
|
|
47
|
+
server.tool('recall', 'Query the thinking graph and Obsidian vault — search by text, filter by type, traverse relationships, or search across projects.', recallSchema.shape, async (input) => recallHandler(graph, input, vault, projectSlug));
|
|
48
|
+
server.tool('learn', 'Store durable knowledge — code facts, tech debt, insights, principles. Writes to both SQLite graph and Obsidian vault.', learnSchema.shape, async (input) => learnHandler(graph, input, vault, projectSlug));
|
|
34
49
|
server.tool('export', 'Export the thinking graph as JSON or a human-readable markdown summary.', exportSchema.shape, async (input) => exportHandler(graph, input));
|
|
35
|
-
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));
|
|
50
|
+
server.tool('research', 'Research a topic using Perplexity/Firecrawl, then ingest findings into the graph and Obsidian vault. Two-phase: call once to get an action plan, then again with findings to store them.', researchSchema.shape, async (input) => researchHandler(graph, input, vault, projectSlug));
|
|
36
51
|
server.tool('recommend-skills', 'Recommend installed marketplace skills by area, verb, platform, or what they produce/detect. Use during reasoning to find skills that can help with the current task.', recommendSkillsSchema.shape, async (input) => recommendSkillsHandler(graph, input));
|
|
37
52
|
// ─── Startup ─────────────────────────────────────────────
|
|
38
53
|
async function main() {
|
|
@@ -46,6 +61,8 @@ async function main() {
|
|
|
46
61
|
if (!memoryOnly) {
|
|
47
62
|
console.error(` DB: ${process.env.THINKING_GRAPH_PROJECT_DB || '.premium/thinking.db'}`);
|
|
48
63
|
}
|
|
64
|
+
console.error(` Vault: ${vault.root}`);
|
|
65
|
+
console.error(` Project: ${projectSlug}`);
|
|
49
66
|
}
|
|
50
67
|
}
|
|
51
68
|
main().catch((err) => {
|
package/dist/tools/learn.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ThinkingGraph } from '../engine/graph.js';
|
|
3
|
+
import type { VaultBridge } from '../vault/bridge.js';
|
|
3
4
|
export declare const learnSchema: z.ZodObject<{
|
|
4
5
|
content: z.ZodString;
|
|
5
6
|
type: z.ZodEnum<["thought", "decision", "insight", "code_fact", "assumption", "detection", "tech_debt", "principle", "pattern", "skill_result", "research"]>;
|
|
@@ -58,7 +59,7 @@ export declare const learnSchema: z.ZodObject<{
|
|
|
58
59
|
violatedBy?: string[] | undefined;
|
|
59
60
|
}>;
|
|
60
61
|
export type LearnInput = z.infer<typeof learnSchema>;
|
|
61
|
-
export declare function learnHandler(graph: ThinkingGraph, input: LearnInput): Promise<{
|
|
62
|
+
export declare function learnHandler(graph: ThinkingGraph, input: LearnInput, vault?: VaultBridge, projectSlug?: string): Promise<{
|
|
62
63
|
content: {
|
|
63
64
|
type: "text";
|
|
64
65
|
text: string;
|
package/dist/tools/learn.js
CHANGED
|
@@ -17,7 +17,7 @@ export const learnSchema = z.object({
|
|
|
17
17
|
violatedBy: z.array(z.string()).optional().describe('Node IDs violating this'),
|
|
18
18
|
metadata: z.record(z.unknown()).optional(),
|
|
19
19
|
});
|
|
20
|
-
export async function learnHandler(graph, input) {
|
|
20
|
+
export async function learnHandler(graph, input, vault, projectSlug) {
|
|
21
21
|
// Check for duplicate
|
|
22
22
|
const existing = await graph.findSimilar(input.content, input.type, input.projectId);
|
|
23
23
|
if (existing) {
|
|
@@ -77,6 +77,30 @@ export async function learnHandler(graph, input) {
|
|
|
77
77
|
relatedCount++;
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
// Write to Obsidian vault (project-scoped)
|
|
81
|
+
let vaultPath = null;
|
|
82
|
+
if (vault && projectSlug) {
|
|
83
|
+
try {
|
|
84
|
+
const title = input.content.slice(0, 80).replace(/[^a-zA-Z0-9 ]/g, '').trim() || input.type;
|
|
85
|
+
vaultPath = vault.write({
|
|
86
|
+
title,
|
|
87
|
+
type: input.type,
|
|
88
|
+
content: input.content,
|
|
89
|
+
projectSlug,
|
|
90
|
+
metadata: {
|
|
91
|
+
nodeId: node.id,
|
|
92
|
+
...(input.filePath && { filePath: input.filePath }),
|
|
93
|
+
...(input.severity && { severity: input.severity }),
|
|
94
|
+
...(input.effort && { effort: input.effort }),
|
|
95
|
+
...(input.impact && { impact: input.impact }),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
// Non-fatal — log and continue
|
|
101
|
+
console.error('Vault write failed:', err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
80
104
|
return {
|
|
81
105
|
content: [{
|
|
82
106
|
type: 'text',
|
|
@@ -85,6 +109,7 @@ export async function learnHandler(graph, input) {
|
|
|
85
109
|
type: node.type,
|
|
86
110
|
relatedCount,
|
|
87
111
|
duplicateOf: null,
|
|
112
|
+
...(vaultPath && { vaultPath }),
|
|
88
113
|
}),
|
|
89
114
|
}],
|
|
90
115
|
};
|
package/dist/tools/recall.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ThinkingGraph } from '../engine/graph.js';
|
|
3
|
+
import type { VaultBridge } from '../vault/bridge.js';
|
|
3
4
|
export declare const recallSchema: z.ZodObject<{
|
|
4
5
|
query: z.ZodOptional<z.ZodString>;
|
|
5
6
|
type: z.ZodOptional<z.ZodUnion<[z.ZodEnum<["thought", "decision", "insight", "code_fact", "assumption", "detection", "tech_debt", "principle", "pattern", "skill_result", "research"]>, z.ZodArray<z.ZodEnum<["thought", "decision", "insight", "code_fact", "assumption", "detection", "tech_debt", "principle", "pattern", "skill_result", "research"]>, "many">]>>;
|
|
@@ -21,12 +22,12 @@ export declare const recallSchema: z.ZodObject<{
|
|
|
21
22
|
metadata?: Record<string, unknown> | undefined;
|
|
22
23
|
query?: string | undefined;
|
|
23
24
|
crossProject?: boolean | undefined;
|
|
25
|
+
limit?: number | undefined;
|
|
24
26
|
relatedTo?: string | undefined;
|
|
25
27
|
edgeType?: "depends_on" | "contradicts" | "supports" | "refines" | "supersedes" | "similar_to" | "located_in" | "violates" | "addresses" | "detected_by" | "invoked_by" | ("depends_on" | "contradicts" | "supports" | "refines" | "supersedes" | "similar_to" | "located_in" | "violates" | "addresses" | "detected_by" | "invoked_by")[] | undefined;
|
|
26
28
|
direction?: "outgoing" | "incoming" | "both" | undefined;
|
|
27
29
|
depth?: number | undefined;
|
|
28
30
|
since?: string | undefined;
|
|
29
|
-
limit?: number | undefined;
|
|
30
31
|
offset?: number | undefined;
|
|
31
32
|
}, {
|
|
32
33
|
type?: "thought" | "decision" | "insight" | "code_fact" | "assumption" | "detection" | "tech_debt" | "principle" | "pattern" | "skill_result" | "research" | ("thought" | "decision" | "insight" | "code_fact" | "assumption" | "detection" | "tech_debt" | "principle" | "pattern" | "skill_result" | "research")[] | undefined;
|
|
@@ -35,16 +36,16 @@ export declare const recallSchema: z.ZodObject<{
|
|
|
35
36
|
metadata?: Record<string, unknown> | undefined;
|
|
36
37
|
query?: string | undefined;
|
|
37
38
|
crossProject?: unknown;
|
|
39
|
+
limit?: number | undefined;
|
|
38
40
|
relatedTo?: string | undefined;
|
|
39
41
|
edgeType?: "depends_on" | "contradicts" | "supports" | "refines" | "supersedes" | "similar_to" | "located_in" | "violates" | "addresses" | "detected_by" | "invoked_by" | ("depends_on" | "contradicts" | "supports" | "refines" | "supersedes" | "similar_to" | "located_in" | "violates" | "addresses" | "detected_by" | "invoked_by")[] | undefined;
|
|
40
42
|
direction?: "outgoing" | "incoming" | "both" | undefined;
|
|
41
43
|
depth?: number | undefined;
|
|
42
44
|
since?: string | undefined;
|
|
43
|
-
limit?: number | undefined;
|
|
44
45
|
offset?: number | undefined;
|
|
45
46
|
}>;
|
|
46
47
|
export type RecallInput = z.infer<typeof recallSchema>;
|
|
47
|
-
export declare function recallHandler(graph: ThinkingGraph, input: RecallInput): Promise<{
|
|
48
|
+
export declare function recallHandler(graph: ThinkingGraph, input: RecallInput, vault?: VaultBridge, projectSlug?: string): Promise<{
|
|
48
49
|
content: {
|
|
49
50
|
type: "text";
|
|
50
51
|
text: string;
|
package/dist/tools/recall.js
CHANGED
|
@@ -16,15 +16,14 @@ export const recallSchema = z.object({
|
|
|
16
16
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
17
17
|
offset: z.coerce.number().int().min(0).optional(),
|
|
18
18
|
});
|
|
19
|
-
export async function recallHandler(graph, input) {
|
|
20
|
-
// If relatedTo is specified, use graph traversal
|
|
19
|
+
export async function recallHandler(graph, input, vault, projectSlug) {
|
|
20
|
+
// If relatedTo is specified, use graph traversal (no vault merge needed)
|
|
21
21
|
if (input.relatedTo) {
|
|
22
22
|
const edgeType = input.edgeType
|
|
23
23
|
? (Array.isArray(input.edgeType) ? input.edgeType[0] : input.edgeType)
|
|
24
24
|
: 'depends_on';
|
|
25
25
|
const depth = input.depth ?? 1;
|
|
26
26
|
const nodes = await graph.traverse(input.relatedTo, edgeType, depth);
|
|
27
|
-
// Enrich with edge previews
|
|
28
27
|
const enriched = await Promise.all(nodes.slice(0, input.limit ?? 20).map(n => graph.getNodeWithEdges(n.id)));
|
|
29
28
|
return {
|
|
30
29
|
content: [{
|
|
@@ -37,7 +36,7 @@ export async function recallHandler(graph, input) {
|
|
|
37
36
|
}],
|
|
38
37
|
};
|
|
39
38
|
}
|
|
40
|
-
// Standard query
|
|
39
|
+
// Standard query — search SQLite graph
|
|
41
40
|
const result = await graph.findNodes({
|
|
42
41
|
query: input.query,
|
|
43
42
|
type: input.type,
|
|
@@ -49,8 +48,18 @@ export async function recallHandler(graph, input) {
|
|
|
49
48
|
limit: input.limit,
|
|
50
49
|
offset: input.offset,
|
|
51
50
|
});
|
|
52
|
-
// Enrich each node with 1-depth edge preview
|
|
53
51
|
const enriched = await Promise.all(result.items.map(n => graph.getNodeWithEdges(n.id)));
|
|
52
|
+
// Also search Obsidian vault if query text is provided
|
|
53
|
+
let vaultResults = [];
|
|
54
|
+
if (vault && projectSlug && input.query) {
|
|
55
|
+
const typeFilter = input.type
|
|
56
|
+
? (Array.isArray(input.type) ? input.type[0] : input.type)
|
|
57
|
+
: undefined;
|
|
58
|
+
vaultResults = vault.search(input.query, projectSlug, {
|
|
59
|
+
limit: input.limit ?? 20,
|
|
60
|
+
type: typeFilter,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
54
63
|
return {
|
|
55
64
|
content: [{
|
|
56
65
|
type: 'text',
|
|
@@ -58,6 +67,10 @@ export async function recallHandler(graph, input) {
|
|
|
58
67
|
nodes: enriched.filter(Boolean),
|
|
59
68
|
totalCount: result.totalCount,
|
|
60
69
|
hasMore: result.hasMore,
|
|
70
|
+
...(vaultResults.length > 0 && {
|
|
71
|
+
vault: vaultResults,
|
|
72
|
+
vaultCount: vaultResults.length,
|
|
73
|
+
}),
|
|
61
74
|
}),
|
|
62
75
|
}],
|
|
63
76
|
};
|
package/dist/tools/research.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ThinkingGraph } from '../engine/graph.js';
|
|
3
|
+
import type { VaultBridge } from '../vault/bridge.js';
|
|
3
4
|
export declare const researchSchema: z.ZodObject<{
|
|
4
5
|
query: z.ZodString;
|
|
5
6
|
intent: z.ZodDefault<z.ZodEnum<["fact_check", "explore", "compare", "how_to", "current_state"]>>;
|
|
@@ -52,7 +53,7 @@ export declare const researchSchema: z.ZodObject<{
|
|
|
52
53
|
domainFilter?: string[] | undefined;
|
|
53
54
|
}>;
|
|
54
55
|
export type ResearchInput = z.infer<typeof researchSchema>;
|
|
55
|
-
export declare function researchHandler(graph: ThinkingGraph, input: ResearchInput): Promise<{
|
|
56
|
+
export declare function researchHandler(graph: ThinkingGraph, input: ResearchInput, vault?: VaultBridge, projectSlug?: string): Promise<{
|
|
56
57
|
content: {
|
|
57
58
|
type: "text";
|
|
58
59
|
text: string;
|
package/dist/tools/research.js
CHANGED
|
@@ -118,7 +118,7 @@ function buildActionPlan(input) {
|
|
|
118
118
|
}
|
|
119
119
|
return steps;
|
|
120
120
|
}
|
|
121
|
-
export async function researchHandler(graph, input) {
|
|
121
|
+
export async function researchHandler(graph, input, vault, projectSlug) {
|
|
122
122
|
// ── Phase 2: ingest findings ──────────────────────────
|
|
123
123
|
if (input.researchId && input.findings?.length) {
|
|
124
124
|
const researchNode = await graph.getNode(input.researchId);
|
|
@@ -132,6 +132,7 @@ export async function researchHandler(graph, input) {
|
|
|
132
132
|
}
|
|
133
133
|
const session = await graph.getCurrentSession();
|
|
134
134
|
const storedNodes = [];
|
|
135
|
+
const vaultPaths = [];
|
|
135
136
|
for (const finding of input.findings) {
|
|
136
137
|
// Check for duplicates
|
|
137
138
|
const existing = await graph.findSimilar(finding.content, 'research', input.projectId);
|
|
@@ -158,6 +159,30 @@ export async function researchHandler(graph, input) {
|
|
|
158
159
|
reasoning: 'Finding from research query',
|
|
159
160
|
});
|
|
160
161
|
storedNodes.push(node.id);
|
|
162
|
+
// Write finding to Obsidian vault
|
|
163
|
+
if (vault && projectSlug) {
|
|
164
|
+
try {
|
|
165
|
+
const title = finding.content.slice(0, 80).replace(/[^a-zA-Z0-9 ]/g, '').trim()
|
|
166
|
+
|| `Research ${input.intent}`;
|
|
167
|
+
const vaultPath = vault.write({
|
|
168
|
+
title,
|
|
169
|
+
type: 'research',
|
|
170
|
+
content: finding.content,
|
|
171
|
+
projectSlug,
|
|
172
|
+
metadata: {
|
|
173
|
+
nodeId: node.id,
|
|
174
|
+
researchQuery: researchNode.content,
|
|
175
|
+
intent: input.intent,
|
|
176
|
+
...(finding.source && { source: finding.source }),
|
|
177
|
+
...(finding.confidence && { confidence: finding.confidence }),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
vaultPaths.push(vaultPath);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
console.error('Vault write failed for research finding:', err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
161
186
|
}
|
|
162
187
|
return {
|
|
163
188
|
content: [{
|
|
@@ -167,6 +192,7 @@ export async function researchHandler(graph, input) {
|
|
|
167
192
|
researchId: input.researchId,
|
|
168
193
|
storedCount: storedNodes.length,
|
|
169
194
|
nodeIds: storedNodes,
|
|
195
|
+
...(vaultPaths.length > 0 && { vaultPaths }),
|
|
170
196
|
suggestions: [
|
|
171
197
|
{
|
|
172
198
|
tool: 'relate',
|
package/dist/tools/think.js
CHANGED
|
@@ -3,13 +3,18 @@ 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
4
|
function buildSuggestions(type, thoughtNumber, relatedCount, stats, matchedSkills) {
|
|
5
5
|
const suggestions = [];
|
|
6
|
-
// First thought in session — suggest recall
|
|
6
|
+
// First thought in session — suggest recall + knowledge vault search
|
|
7
7
|
if (thoughtNumber === 1 && stats.totalNodes > 1) {
|
|
8
8
|
suggestions.push({
|
|
9
9
|
tool: 'recall',
|
|
10
|
-
when: 'Check what the graph already knows before reasoning from scratch',
|
|
10
|
+
when: 'Check what the thinking graph already knows before reasoning from scratch',
|
|
11
11
|
example: { query: '<topic>', crossProject: true },
|
|
12
12
|
});
|
|
13
|
+
suggestions.push({
|
|
14
|
+
tool: 'kg_search',
|
|
15
|
+
when: 'Search the knowledge vault for semantically related insights, patterns, and facts from past work',
|
|
16
|
+
example: { query: '<topic>', type: 'semantic', limit: 5 },
|
|
17
|
+
});
|
|
13
18
|
}
|
|
14
19
|
// Decision nodes with no relationships — suggest relate
|
|
15
20
|
if (type === 'decision' && relatedCount === 0) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Obsidian vault bridge for thinking-graph.
|
|
3
|
+
*
|
|
4
|
+
* Reads and writes markdown files with YAML frontmatter to an Obsidian vault,
|
|
5
|
+
* scoped by project. Does NOT depend on the knowledge-graph package — it uses
|
|
6
|
+
* gray-matter and simple file I/O so the two MCP servers stay decoupled.
|
|
7
|
+
*
|
|
8
|
+
* knowledge-graph can later re-index the same vault for semantic search,
|
|
9
|
+
* community detection, and graph analytics.
|
|
10
|
+
*/
|
|
11
|
+
import type { NodeType } from '../engine/types.js';
|
|
12
|
+
export interface VaultNote {
|
|
13
|
+
relPath: string;
|
|
14
|
+
title: string;
|
|
15
|
+
content: string;
|
|
16
|
+
frontmatter: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface VaultSearchResult {
|
|
19
|
+
relPath: string;
|
|
20
|
+
title: string;
|
|
21
|
+
excerpt: string;
|
|
22
|
+
score: number;
|
|
23
|
+
source: 'vault';
|
|
24
|
+
}
|
|
25
|
+
export interface VaultWriteOpts {
|
|
26
|
+
title: string;
|
|
27
|
+
type: NodeType;
|
|
28
|
+
content: string;
|
|
29
|
+
projectSlug: string;
|
|
30
|
+
metadata?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
export declare class VaultBridge {
|
|
33
|
+
private vaultRoot;
|
|
34
|
+
constructor(vaultPath: string);
|
|
35
|
+
get root(): string;
|
|
36
|
+
/** Resolve the directory for a given project + node type. */
|
|
37
|
+
private projectDir;
|
|
38
|
+
/**
|
|
39
|
+
* Write a note to the vault. Returns the relative path within the vault.
|
|
40
|
+
* If a file with the same title already exists, appends a timestamp suffix.
|
|
41
|
+
*/
|
|
42
|
+
write(opts: VaultWriteOpts): string;
|
|
43
|
+
/** Read a single note by relative path. */
|
|
44
|
+
read(relPath: string): VaultNote | null;
|
|
45
|
+
/**
|
|
46
|
+
* Full-text search across the project's vault directory.
|
|
47
|
+
* Uses Jaccard token overlap for scoring (same algo as dedup).
|
|
48
|
+
*/
|
|
49
|
+
search(query: string, projectSlug: string, opts?: {
|
|
50
|
+
limit?: number;
|
|
51
|
+
type?: string;
|
|
52
|
+
}): VaultSearchResult[];
|
|
53
|
+
/**
|
|
54
|
+
* List all notes for a project, optionally filtered by type.
|
|
55
|
+
*/
|
|
56
|
+
list(projectSlug: string, type?: string): VaultNote[];
|
|
57
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Obsidian vault bridge for thinking-graph.
|
|
3
|
+
*
|
|
4
|
+
* Reads and writes markdown files with YAML frontmatter to an Obsidian vault,
|
|
5
|
+
* scoped by project. Does NOT depend on the knowledge-graph package — it uses
|
|
6
|
+
* gray-matter and simple file I/O so the two MCP servers stay decoupled.
|
|
7
|
+
*
|
|
8
|
+
* knowledge-graph can later re-index the same vault for semantic search,
|
|
9
|
+
* community detection, and graph analytics.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
12
|
+
import { join, basename, relative, extname } from 'path';
|
|
13
|
+
import { homedir } from 'os';
|
|
14
|
+
import matter from 'gray-matter';
|
|
15
|
+
import { tokenize } from '../engine/dedup.js';
|
|
16
|
+
// ─── Directory mapping ────────────────────────────────
|
|
17
|
+
const TYPE_DIRS = {
|
|
18
|
+
insight: 'Insights',
|
|
19
|
+
pattern: 'Patterns',
|
|
20
|
+
principle: 'Principles',
|
|
21
|
+
decision: 'Decisions',
|
|
22
|
+
code_fact: 'Code Facts',
|
|
23
|
+
tech_debt: 'Tech Debt',
|
|
24
|
+
research: 'Research',
|
|
25
|
+
thought: 'Thoughts',
|
|
26
|
+
assumption: 'Assumptions',
|
|
27
|
+
detection: 'Detections',
|
|
28
|
+
skill_result: 'Skill Results',
|
|
29
|
+
};
|
|
30
|
+
// ─── Helpers ──────────────────────────────────────────
|
|
31
|
+
function expandHome(p) {
|
|
32
|
+
if (p.startsWith('~/'))
|
|
33
|
+
return join(homedir(), p.slice(2));
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
function sanitizeFilename(name) {
|
|
37
|
+
return name
|
|
38
|
+
.replace(/[<>:"/\\|?*]/g, '')
|
|
39
|
+
.replace(/\s+/g, ' ')
|
|
40
|
+
.trim()
|
|
41
|
+
.slice(0, 120);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Walk a directory tree and yield .md files.
|
|
45
|
+
*/
|
|
46
|
+
function* walkMd(dir) {
|
|
47
|
+
if (!existsSync(dir))
|
|
48
|
+
return;
|
|
49
|
+
for (const entry of readdirSync(dir)) {
|
|
50
|
+
if (entry.startsWith('.'))
|
|
51
|
+
continue;
|
|
52
|
+
const full = join(dir, entry);
|
|
53
|
+
const stat = statSync(full);
|
|
54
|
+
if (stat.isDirectory()) {
|
|
55
|
+
yield* walkMd(full);
|
|
56
|
+
}
|
|
57
|
+
else if (extname(entry) === '.md') {
|
|
58
|
+
yield full;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ─── VaultBridge ──────────────────────────────────────
|
|
63
|
+
export class VaultBridge {
|
|
64
|
+
vaultRoot;
|
|
65
|
+
constructor(vaultPath) {
|
|
66
|
+
this.vaultRoot = expandHome(vaultPath);
|
|
67
|
+
}
|
|
68
|
+
get root() {
|
|
69
|
+
return this.vaultRoot;
|
|
70
|
+
}
|
|
71
|
+
/** Resolve the directory for a given project + node type. */
|
|
72
|
+
projectDir(projectSlug, type) {
|
|
73
|
+
const base = join(this.vaultRoot, projectSlug);
|
|
74
|
+
if (!type)
|
|
75
|
+
return base;
|
|
76
|
+
const subdir = TYPE_DIRS[type] ?? type;
|
|
77
|
+
return join(base, subdir);
|
|
78
|
+
}
|
|
79
|
+
// ─── Write ──────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* Write a note to the vault. Returns the relative path within the vault.
|
|
82
|
+
* If a file with the same title already exists, appends a timestamp suffix.
|
|
83
|
+
*/
|
|
84
|
+
write(opts) {
|
|
85
|
+
const dir = this.projectDir(opts.projectSlug, opts.type);
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
let filename = `${sanitizeFilename(opts.title)}.md`;
|
|
88
|
+
let absPath = join(dir, filename);
|
|
89
|
+
// Deduplicate filename
|
|
90
|
+
if (existsSync(absPath)) {
|
|
91
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
92
|
+
filename = `${sanitizeFilename(opts.title)} ${ts}.md`;
|
|
93
|
+
absPath = join(dir, filename);
|
|
94
|
+
}
|
|
95
|
+
const fm = {
|
|
96
|
+
title: opts.title,
|
|
97
|
+
type: opts.type,
|
|
98
|
+
project: opts.projectSlug,
|
|
99
|
+
created: new Date().toISOString(),
|
|
100
|
+
...opts.metadata,
|
|
101
|
+
};
|
|
102
|
+
const fileContent = matter.stringify(opts.content, fm);
|
|
103
|
+
writeFileSync(absPath, fileContent, 'utf-8');
|
|
104
|
+
return relative(this.vaultRoot, absPath);
|
|
105
|
+
}
|
|
106
|
+
// ─── Read ───────────────────────────────────────────
|
|
107
|
+
/** Read a single note by relative path. */
|
|
108
|
+
read(relPath) {
|
|
109
|
+
const absPath = join(this.vaultRoot, relPath);
|
|
110
|
+
if (!existsSync(absPath))
|
|
111
|
+
return null;
|
|
112
|
+
const raw = readFileSync(absPath, 'utf-8');
|
|
113
|
+
const parsed = matter(raw);
|
|
114
|
+
return {
|
|
115
|
+
relPath,
|
|
116
|
+
title: parsed.data.title ?? basename(relPath, '.md'),
|
|
117
|
+
content: parsed.content,
|
|
118
|
+
frontmatter: parsed.data,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// ─── Search ─────────────────────────────────────────
|
|
122
|
+
/**
|
|
123
|
+
* Full-text search across the project's vault directory.
|
|
124
|
+
* Uses Jaccard token overlap for scoring (same algo as dedup).
|
|
125
|
+
*/
|
|
126
|
+
search(query, projectSlug, opts) {
|
|
127
|
+
const dir = opts?.type
|
|
128
|
+
? this.projectDir(projectSlug, opts.type)
|
|
129
|
+
: this.projectDir(projectSlug);
|
|
130
|
+
const queryTokens = new Set(tokenize(query));
|
|
131
|
+
if (queryTokens.size === 0)
|
|
132
|
+
return [];
|
|
133
|
+
const results = [];
|
|
134
|
+
const limit = opts?.limit ?? 20;
|
|
135
|
+
for (const absPath of walkMd(dir)) {
|
|
136
|
+
const raw = readFileSync(absPath, 'utf-8');
|
|
137
|
+
const parsed = matter(raw);
|
|
138
|
+
const text = parsed.content;
|
|
139
|
+
const title = parsed.data.title ?? basename(absPath, '.md');
|
|
140
|
+
// Score: Jaccard overlap on content tokens
|
|
141
|
+
const contentTokens = new Set(tokenize(text));
|
|
142
|
+
if (contentTokens.size === 0)
|
|
143
|
+
continue;
|
|
144
|
+
const intersection = [...queryTokens].filter(t => contentTokens.has(t)).length;
|
|
145
|
+
if (intersection === 0)
|
|
146
|
+
continue;
|
|
147
|
+
const union = new Set([...queryTokens, ...contentTokens]).size;
|
|
148
|
+
const score = intersection / union;
|
|
149
|
+
// Extract an excerpt around the first matching token
|
|
150
|
+
const excerpt = extractExcerpt(text, query);
|
|
151
|
+
results.push({
|
|
152
|
+
relPath: relative(this.vaultRoot, absPath),
|
|
153
|
+
title,
|
|
154
|
+
excerpt,
|
|
155
|
+
score,
|
|
156
|
+
source: 'vault',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Sort by score descending, take top N
|
|
160
|
+
results.sort((a, b) => b.score - a.score);
|
|
161
|
+
return results.slice(0, limit);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* List all notes for a project, optionally filtered by type.
|
|
165
|
+
*/
|
|
166
|
+
list(projectSlug, type) {
|
|
167
|
+
const dir = type
|
|
168
|
+
? this.projectDir(projectSlug, type)
|
|
169
|
+
: this.projectDir(projectSlug);
|
|
170
|
+
const notes = [];
|
|
171
|
+
for (const absPath of walkMd(dir)) {
|
|
172
|
+
const raw = readFileSync(absPath, 'utf-8');
|
|
173
|
+
const parsed = matter(raw);
|
|
174
|
+
notes.push({
|
|
175
|
+
relPath: relative(this.vaultRoot, absPath),
|
|
176
|
+
title: parsed.data.title ?? basename(absPath, '.md'),
|
|
177
|
+
content: parsed.content,
|
|
178
|
+
frontmatter: parsed.data,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return notes;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// ─── Utility ──────────────────────────────────────────
|
|
185
|
+
function extractExcerpt(text, query, radius = 120) {
|
|
186
|
+
const lower = text.toLowerCase();
|
|
187
|
+
const words = query.toLowerCase().split(/\W+/).filter(Boolean);
|
|
188
|
+
let bestIdx = -1;
|
|
189
|
+
for (const w of words) {
|
|
190
|
+
const idx = lower.indexOf(w);
|
|
191
|
+
if (idx !== -1) {
|
|
192
|
+
bestIdx = idx;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (bestIdx === -1)
|
|
197
|
+
return text.slice(0, radius * 2);
|
|
198
|
+
const start = Math.max(0, bestIdx - radius);
|
|
199
|
+
const end = Math.min(text.length, bestIdx + radius);
|
|
200
|
+
let excerpt = text.slice(start, end).trim();
|
|
201
|
+
if (start > 0)
|
|
202
|
+
excerpt = '...' + excerpt;
|
|
203
|
+
if (end < text.length)
|
|
204
|
+
excerpt += '...';
|
|
205
|
+
return excerpt;
|
|
206
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feelingmindful/thinking-graph",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
34
|
+
"gray-matter": "^4.0.3",
|
|
34
35
|
"sql.js": "^1.12.0",
|
|
35
36
|
"uuid": "^10.0.0",
|
|
36
37
|
"zod": "^3.23.0"
|