@feelingmindful/thinking-graph 1.9.0 → 1.10.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/index.js +20 -3
- package/dist/tools/learn.d.ts +2 -1
- package/dist/tools/learn.js +25 -34
- package/dist/tools/recall.d.ts +2 -1
- package/dist/tools/recall.js +18 -5
- package/dist/tools/research.d.ts +2 -1
- package/dist/tools/research.js +27 -1
- package/dist/vault/bridge.d.ts +62 -0
- package/dist/vault/bridge.js +231 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { NODE_TYPES, EDGE_TYPES
|
|
2
|
+
import { NODE_TYPES, EDGE_TYPES } from '../engine/types.js';
|
|
3
|
+
import { deriveTitle } from '../vault/bridge.js';
|
|
3
4
|
export const learnSchema = z.object({
|
|
4
5
|
content: z.string().describe('What was learned'),
|
|
5
6
|
type: z.enum(NODE_TYPES).describe('Node type'),
|
|
@@ -17,7 +18,7 @@ export const learnSchema = z.object({
|
|
|
17
18
|
violatedBy: z.array(z.string()).optional().describe('Node IDs violating this'),
|
|
18
19
|
metadata: z.record(z.unknown()).optional(),
|
|
19
20
|
});
|
|
20
|
-
export async function learnHandler(graph, input) {
|
|
21
|
+
export async function learnHandler(graph, input, vault, projectSlug) {
|
|
21
22
|
// Check for duplicate
|
|
22
23
|
const existing = await graph.findSimilar(input.content, input.type, input.projectId);
|
|
23
24
|
if (existing) {
|
|
@@ -77,39 +78,29 @@ export async function learnHandler(graph, input) {
|
|
|
77
78
|
relatedCount++;
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
directory: input.type === 'insight' ? 'Insights' : input.type === 'pattern' ? 'Patterns' : 'Principles',
|
|
81
|
+
// Write to Obsidian vault (project-scoped)
|
|
82
|
+
let vaultPath = null;
|
|
83
|
+
if (vault && projectSlug) {
|
|
84
|
+
try {
|
|
85
|
+
const title = deriveTitle(input.content, input.type);
|
|
86
|
+
vaultPath = vault.write({
|
|
87
|
+
title,
|
|
88
|
+
type: input.type,
|
|
89
89
|
content: input.content,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
...(input.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
if (input.type === 'code_fact' && input.filePath) {
|
|
99
|
-
suggestions.push({
|
|
100
|
-
tool: 'kg_create_node',
|
|
101
|
-
when: 'Persist this code fact to the knowledge vault for future recall across projects',
|
|
102
|
-
example: {
|
|
103
|
-
title: input.content.slice(0, 60).replace(/[^a-zA-Z0-9 ]/g, ''),
|
|
104
|
-
directory: 'Code Facts',
|
|
105
|
-
content: input.content,
|
|
106
|
-
frontmatter: {
|
|
107
|
-
type: 'code_fact',
|
|
108
|
-
filePath: input.filePath,
|
|
109
|
-
tags: ['code-fact'],
|
|
90
|
+
projectSlug,
|
|
91
|
+
metadata: {
|
|
92
|
+
nodeId: node.id,
|
|
93
|
+
...(input.filePath && { filePath: input.filePath }),
|
|
94
|
+
...(input.severity && { severity: input.severity }),
|
|
95
|
+
...(input.effort && { effort: input.effort }),
|
|
96
|
+
...(input.impact && { impact: input.impact }),
|
|
110
97
|
},
|
|
111
|
-
}
|
|
112
|
-
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// Non-fatal — log and continue
|
|
102
|
+
console.error('Vault write failed:', err);
|
|
103
|
+
}
|
|
113
104
|
}
|
|
114
105
|
return {
|
|
115
106
|
content: [{
|
|
@@ -119,7 +110,7 @@ export async function learnHandler(graph, input) {
|
|
|
119
110
|
type: node.type,
|
|
120
111
|
relatedCount,
|
|
121
112
|
duplicateOf: null,
|
|
122
|
-
...(
|
|
113
|
+
...(vaultPath && { vaultPath }),
|
|
123
114
|
}),
|
|
124
115
|
}],
|
|
125
116
|
};
|
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">]>>;
|
|
@@ -44,7 +45,7 @@ export declare const recallSchema: z.ZodObject<{
|
|
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { deriveTitle } from '../vault/bridge.js';
|
|
2
3
|
const coerceBool = z.preprocess((v) => (v === 'true' ? true : v === 'false' ? false : v), z.boolean());
|
|
3
4
|
const RESEARCH_INTENTS = [
|
|
4
5
|
'fact_check', // Verify a claim or assumption
|
|
@@ -118,7 +119,7 @@ function buildActionPlan(input) {
|
|
|
118
119
|
}
|
|
119
120
|
return steps;
|
|
120
121
|
}
|
|
121
|
-
export async function researchHandler(graph, input) {
|
|
122
|
+
export async function researchHandler(graph, input, vault, projectSlug) {
|
|
122
123
|
// ── Phase 2: ingest findings ──────────────────────────
|
|
123
124
|
if (input.researchId && input.findings?.length) {
|
|
124
125
|
const researchNode = await graph.getNode(input.researchId);
|
|
@@ -132,6 +133,7 @@ export async function researchHandler(graph, input) {
|
|
|
132
133
|
}
|
|
133
134
|
const session = await graph.getCurrentSession();
|
|
134
135
|
const storedNodes = [];
|
|
136
|
+
const vaultPaths = [];
|
|
135
137
|
for (const finding of input.findings) {
|
|
136
138
|
// Check for duplicates
|
|
137
139
|
const existing = await graph.findSimilar(finding.content, 'research', input.projectId);
|
|
@@ -158,6 +160,29 @@ export async function researchHandler(graph, input) {
|
|
|
158
160
|
reasoning: 'Finding from research query',
|
|
159
161
|
});
|
|
160
162
|
storedNodes.push(node.id);
|
|
163
|
+
// Write finding to Obsidian vault
|
|
164
|
+
if (vault && projectSlug) {
|
|
165
|
+
try {
|
|
166
|
+
const title = deriveTitle(finding.content, `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',
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
/**
|
|
33
|
+
* Derive a short, meaningful title from freeform content.
|
|
34
|
+
* Takes the first sentence (or first N words) and sanitizes it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function deriveTitle(content: string, fallback: string): string;
|
|
37
|
+
export declare class VaultBridge {
|
|
38
|
+
private vaultRoot;
|
|
39
|
+
constructor(vaultPath: string);
|
|
40
|
+
get root(): string;
|
|
41
|
+
/** Resolve the directory for a given project + node type. */
|
|
42
|
+
private projectDir;
|
|
43
|
+
/**
|
|
44
|
+
* Write a note to the vault. Returns the relative path within the vault.
|
|
45
|
+
* If a file with the same title already exists, appends a timestamp suffix.
|
|
46
|
+
*/
|
|
47
|
+
write(opts: VaultWriteOpts): string;
|
|
48
|
+
/** Read a single note by relative path. */
|
|
49
|
+
read(relPath: string): VaultNote | null;
|
|
50
|
+
/**
|
|
51
|
+
* Full-text search across the project's vault directory.
|
|
52
|
+
* Uses Jaccard token overlap for scoring (same algo as dedup).
|
|
53
|
+
*/
|
|
54
|
+
search(query: string, projectSlug: string, opts?: {
|
|
55
|
+
limit?: number;
|
|
56
|
+
type?: string;
|
|
57
|
+
}): VaultSearchResult[];
|
|
58
|
+
/**
|
|
59
|
+
* List all notes for a project, optionally filtered by type.
|
|
60
|
+
*/
|
|
61
|
+
list(projectSlug: string, type?: string): VaultNote[];
|
|
62
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
// Obsidian titles should stay under 60 chars for clean sidebar display.
|
|
37
|
+
// macOS allows 255 bytes but long titles break Obsidian search and links.
|
|
38
|
+
const MAX_TITLE_LENGTH = 60;
|
|
39
|
+
function sanitizeFilename(name) {
|
|
40
|
+
return name
|
|
41
|
+
.replace(/[<>:"/\\|?*#^\[\]]/g, '') // filesystem + Obsidian-reserved chars
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim()
|
|
44
|
+
.slice(0, MAX_TITLE_LENGTH);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Derive a short, meaningful title from freeform content.
|
|
48
|
+
* Takes the first sentence (or first N words) and sanitizes it.
|
|
49
|
+
*/
|
|
50
|
+
export function deriveTitle(content, fallback) {
|
|
51
|
+
// Strip leading markdown headings/bullets
|
|
52
|
+
const cleaned = content.replace(/^[#\-*>\s]+/, '').trim();
|
|
53
|
+
if (!cleaned)
|
|
54
|
+
return sanitizeFilename(fallback);
|
|
55
|
+
// Take first sentence (up to period, newline, or em-dash)
|
|
56
|
+
const firstSentence = cleaned.split(/[.\n—]/, 1)[0].trim();
|
|
57
|
+
// If the sentence is short enough, use it
|
|
58
|
+
if (firstSentence.length > 0 && firstSentence.length <= MAX_TITLE_LENGTH) {
|
|
59
|
+
const title = firstSentence.replace(/[^a-zA-Z0-9 \-_]/g, '').trim();
|
|
60
|
+
if (title.length >= 5)
|
|
61
|
+
return sanitizeFilename(title);
|
|
62
|
+
}
|
|
63
|
+
// Otherwise take first ~8 words
|
|
64
|
+
const words = cleaned.replace(/[^a-zA-Z0-9 \-_]/g, '').split(/\s+/).filter(Boolean);
|
|
65
|
+
const shortTitle = words.slice(0, 8).join(' ');
|
|
66
|
+
return sanitizeFilename(shortTitle || fallback);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Walk a directory tree and yield .md files.
|
|
70
|
+
*/
|
|
71
|
+
function* walkMd(dir) {
|
|
72
|
+
if (!existsSync(dir))
|
|
73
|
+
return;
|
|
74
|
+
for (const entry of readdirSync(dir)) {
|
|
75
|
+
if (entry.startsWith('.'))
|
|
76
|
+
continue;
|
|
77
|
+
const full = join(dir, entry);
|
|
78
|
+
const stat = statSync(full);
|
|
79
|
+
if (stat.isDirectory()) {
|
|
80
|
+
yield* walkMd(full);
|
|
81
|
+
}
|
|
82
|
+
else if (extname(entry) === '.md') {
|
|
83
|
+
yield full;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ─── VaultBridge ──────────────────────────────────────
|
|
88
|
+
export class VaultBridge {
|
|
89
|
+
vaultRoot;
|
|
90
|
+
constructor(vaultPath) {
|
|
91
|
+
this.vaultRoot = expandHome(vaultPath);
|
|
92
|
+
}
|
|
93
|
+
get root() {
|
|
94
|
+
return this.vaultRoot;
|
|
95
|
+
}
|
|
96
|
+
/** Resolve the directory for a given project + node type. */
|
|
97
|
+
projectDir(projectSlug, type) {
|
|
98
|
+
const base = join(this.vaultRoot, projectSlug);
|
|
99
|
+
if (!type)
|
|
100
|
+
return base;
|
|
101
|
+
const subdir = TYPE_DIRS[type] ?? type;
|
|
102
|
+
return join(base, subdir);
|
|
103
|
+
}
|
|
104
|
+
// ─── Write ──────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Write a note to the vault. Returns the relative path within the vault.
|
|
107
|
+
* If a file with the same title already exists, appends a timestamp suffix.
|
|
108
|
+
*/
|
|
109
|
+
write(opts) {
|
|
110
|
+
const dir = this.projectDir(opts.projectSlug, opts.type);
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
let filename = `${sanitizeFilename(opts.title)}.md`;
|
|
113
|
+
let absPath = join(dir, filename);
|
|
114
|
+
// Deduplicate filename
|
|
115
|
+
if (existsSync(absPath)) {
|
|
116
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
117
|
+
filename = `${sanitizeFilename(opts.title)} ${ts}.md`;
|
|
118
|
+
absPath = join(dir, filename);
|
|
119
|
+
}
|
|
120
|
+
const fm = {
|
|
121
|
+
title: opts.title,
|
|
122
|
+
type: opts.type,
|
|
123
|
+
project: opts.projectSlug,
|
|
124
|
+
created: new Date().toISOString(),
|
|
125
|
+
...opts.metadata,
|
|
126
|
+
};
|
|
127
|
+
const fileContent = matter.stringify(opts.content, fm);
|
|
128
|
+
writeFileSync(absPath, fileContent, 'utf-8');
|
|
129
|
+
return relative(this.vaultRoot, absPath);
|
|
130
|
+
}
|
|
131
|
+
// ─── Read ───────────────────────────────────────────
|
|
132
|
+
/** Read a single note by relative path. */
|
|
133
|
+
read(relPath) {
|
|
134
|
+
const absPath = join(this.vaultRoot, relPath);
|
|
135
|
+
if (!existsSync(absPath))
|
|
136
|
+
return null;
|
|
137
|
+
const raw = readFileSync(absPath, 'utf-8');
|
|
138
|
+
const parsed = matter(raw);
|
|
139
|
+
return {
|
|
140
|
+
relPath,
|
|
141
|
+
title: parsed.data.title ?? basename(relPath, '.md'),
|
|
142
|
+
content: parsed.content,
|
|
143
|
+
frontmatter: parsed.data,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// ─── Search ─────────────────────────────────────────
|
|
147
|
+
/**
|
|
148
|
+
* Full-text search across the project's vault directory.
|
|
149
|
+
* Uses Jaccard token overlap for scoring (same algo as dedup).
|
|
150
|
+
*/
|
|
151
|
+
search(query, projectSlug, opts) {
|
|
152
|
+
const dir = opts?.type
|
|
153
|
+
? this.projectDir(projectSlug, opts.type)
|
|
154
|
+
: this.projectDir(projectSlug);
|
|
155
|
+
const queryTokens = new Set(tokenize(query));
|
|
156
|
+
if (queryTokens.size === 0)
|
|
157
|
+
return [];
|
|
158
|
+
const results = [];
|
|
159
|
+
const limit = opts?.limit ?? 20;
|
|
160
|
+
for (const absPath of walkMd(dir)) {
|
|
161
|
+
const raw = readFileSync(absPath, 'utf-8');
|
|
162
|
+
const parsed = matter(raw);
|
|
163
|
+
const text = parsed.content;
|
|
164
|
+
const title = parsed.data.title ?? basename(absPath, '.md');
|
|
165
|
+
// Score: Jaccard overlap on content tokens
|
|
166
|
+
const contentTokens = new Set(tokenize(text));
|
|
167
|
+
if (contentTokens.size === 0)
|
|
168
|
+
continue;
|
|
169
|
+
const intersection = [...queryTokens].filter(t => contentTokens.has(t)).length;
|
|
170
|
+
if (intersection === 0)
|
|
171
|
+
continue;
|
|
172
|
+
const union = new Set([...queryTokens, ...contentTokens]).size;
|
|
173
|
+
const score = intersection / union;
|
|
174
|
+
// Extract an excerpt around the first matching token
|
|
175
|
+
const excerpt = extractExcerpt(text, query);
|
|
176
|
+
results.push({
|
|
177
|
+
relPath: relative(this.vaultRoot, absPath),
|
|
178
|
+
title,
|
|
179
|
+
excerpt,
|
|
180
|
+
score,
|
|
181
|
+
source: 'vault',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// Sort by score descending, take top N
|
|
185
|
+
results.sort((a, b) => b.score - a.score);
|
|
186
|
+
return results.slice(0, limit);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* List all notes for a project, optionally filtered by type.
|
|
190
|
+
*/
|
|
191
|
+
list(projectSlug, type) {
|
|
192
|
+
const dir = type
|
|
193
|
+
? this.projectDir(projectSlug, type)
|
|
194
|
+
: this.projectDir(projectSlug);
|
|
195
|
+
const notes = [];
|
|
196
|
+
for (const absPath of walkMd(dir)) {
|
|
197
|
+
const raw = readFileSync(absPath, 'utf-8');
|
|
198
|
+
const parsed = matter(raw);
|
|
199
|
+
notes.push({
|
|
200
|
+
relPath: relative(this.vaultRoot, absPath),
|
|
201
|
+
title: parsed.data.title ?? basename(absPath, '.md'),
|
|
202
|
+
content: parsed.content,
|
|
203
|
+
frontmatter: parsed.data,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return notes;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// ─── Utility ──────────────────────────────────────────
|
|
210
|
+
function extractExcerpt(text, query, radius = 120) {
|
|
211
|
+
const lower = text.toLowerCase();
|
|
212
|
+
const words = query.toLowerCase().split(/\W+/).filter(Boolean);
|
|
213
|
+
let bestIdx = -1;
|
|
214
|
+
for (const w of words) {
|
|
215
|
+
const idx = lower.indexOf(w);
|
|
216
|
+
if (idx !== -1) {
|
|
217
|
+
bestIdx = idx;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (bestIdx === -1)
|
|
222
|
+
return text.slice(0, radius * 2);
|
|
223
|
+
const start = Math.max(0, bestIdx - radius);
|
|
224
|
+
const end = Math.min(text.length, bestIdx + radius);
|
|
225
|
+
let excerpt = text.slice(start, end).trim();
|
|
226
|
+
if (start > 0)
|
|
227
|
+
excerpt = '...' + excerpt;
|
|
228
|
+
if (end < text.length)
|
|
229
|
+
excerpt += '...';
|
|
230
|
+
return excerpt;
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feelingmindful/thinking-graph",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
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"
|