@feelingmindful/thinking-graph 1.9.0 → 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 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. Deduplicates similar content.', learnSchema.shape, async (input) => learnHandler(graph, input));
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) => {
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { NODE_TYPES, EDGE_TYPES, GLOBAL_NODE_TYPES } from '../engine/types.js';
2
+ import { NODE_TYPES, EDGE_TYPES } from '../engine/types.js';
3
3
  export const learnSchema = z.object({
4
4
  content: z.string().describe('What was learned'),
5
5
  type: z.enum(NODE_TYPES).describe('Node type'),
@@ -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,39 +77,29 @@ export async function learnHandler(graph, input) {
77
77
  relatedCount++;
78
78
  }
79
79
  }
80
- // Suggest writing to the knowledge-graph vault for durable, cross-project knowledge
81
- const suggestions = [];
82
- if (GLOBAL_NODE_TYPES.includes(input.type)) {
83
- suggestions.push({
84
- tool: 'kg_create_node',
85
- when: 'Persist this to the knowledge vault for semantic search, community detection, and cross-project discovery',
86
- example: {
87
- title: input.content.slice(0, 60).replace(/[^a-zA-Z0-9 ]/g, ''),
88
- directory: input.type === 'insight' ? 'Insights' : input.type === 'pattern' ? 'Patterns' : 'Principles',
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,
89
88
  content: input.content,
90
- frontmatter: {
91
- type: input.type,
92
- tags: [input.type],
93
- ...(input.projectId && { project: input.projectId }),
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'],
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 }),
110
96
  },
111
- },
112
- });
97
+ });
98
+ }
99
+ catch (err) {
100
+ // Non-fatal — log and continue
101
+ console.error('Vault write failed:', err);
102
+ }
113
103
  }
114
104
  return {
115
105
  content: [{
@@ -119,7 +109,7 @@ export async function learnHandler(graph, input) {
119
109
  type: node.type,
120
110
  relatedCount,
121
111
  duplicateOf: null,
122
- ...(suggestions.length > 0 && { suggestions }),
112
+ ...(vaultPath && { vaultPath }),
123
113
  }),
124
114
  }],
125
115
  };
@@ -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;
@@ -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
  };
@@ -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;
@@ -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',
@@ -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.9.0",
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"