@feelingmindful/thinking-graph 1.12.0 → 1.14.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.
@@ -37,6 +37,17 @@ export async function seedSkillRegistry(storage) {
37
37
  return count;
38
38
  }
39
39
  export async function seedAll(storage) {
40
+ // Ensure a 'seed' session exists for FK constraint on seed nodes
41
+ const seedSession = await storage.getSession('seed');
42
+ if (!seedSession) {
43
+ const now = new Date().toISOString();
44
+ await storage.insertSession({
45
+ id: 'seed',
46
+ description: 'Seed data session',
47
+ startedAt: now,
48
+ lastActiveAt: now,
49
+ });
50
+ }
40
51
  const principles = await seedPrinciples(storage);
41
52
  const skills = await seedSkillRegistry(storage);
42
53
  return { principles, skills };
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
2
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
6
  import { ThinkingGraph } from './engine/graph.js';
@@ -17,15 +19,18 @@ import { planSkillsSchema, planSkillsHandler } from './tools/plan-skills.js';
17
19
  import { executePlanSchema, executePlanHandler } from './tools/execute-plan.js';
18
20
  import { executeSkillsSchema, executeSkillsHandler } from './tools/execute-skills.js';
19
21
  // Legacy compat shim removed — use `think` tool directly
22
+ function expandHome(p) {
23
+ return p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
24
+ }
20
25
  // ─── Storage setup ───────────────────────────────────────
21
26
  const memoryOnly = process.env.THINKING_GRAPH_MEMORY_ONLY === 'true';
22
27
  const storage = memoryOnly
23
28
  ? new InMemoryAdapter()
24
29
  : new SQLiteAdapter({
25
- dbPath: process.env.THINKING_GRAPH_PROJECT_DB || '.premium/thinking.db',
30
+ dbPath: expandHome(process.env.THINKING_GRAPH_PROJECT_DB || '.premium/thinking.db'),
26
31
  });
27
32
  // ─── Vault bridge ────────────────────────────────────────
28
- const vaultPath = process.env.THINKING_GRAPH_VAULT_PATH || '~/Documents/Obsidian/Dev';
33
+ const vaultPath = expandHome(process.env.THINKING_GRAPH_VAULT_PATH || '~/Documents/Obsidian/Dev');
29
34
  const vault = new VaultBridge(vaultPath);
30
35
  // Derive project slug from env or DB path (e.g. "feeling-mindful-plugins")
31
36
  const projectSlug = process.env.THINKING_GRAPH_PROJECT_SLUG
@@ -5,14 +5,14 @@ export interface SQLiteAdapterOpts {
5
5
  }
6
6
  export declare class SQLiteAdapter implements StorageAdapter {
7
7
  private db;
8
- private dbPath;
8
+ private readonly dbPath;
9
9
  constructor(opts: SQLiteAdapterOpts);
10
10
  initialize(): Promise<void>;
11
11
  close(): Promise<void>;
12
- private persist;
13
- private runSql;
14
- private getSingle;
15
- private getAll;
12
+ private backfillFts;
13
+ private run;
14
+ private get;
15
+ private all;
16
16
  insertNode(node: Node): Promise<void>;
17
17
  getNode(id: string): Promise<Node | null>;
18
18
  queryNodes(query: NodeQuery): Promise<PaginatedResult<Node>>;
@@ -1,17 +1,10 @@
1
- import initSqlJs from 'sql.js';
2
- import { readFileSync, writeFileSync, existsSync } from 'fs';
1
+ import Database from 'better-sqlite3';
2
+ import { readFileSync } from 'fs';
3
3
  import { join, dirname } from 'path';
4
4
  import { mkdirSync } from 'fs';
5
5
  import { fileURLToPath } from 'url';
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const MIGRATION_PATH = join(__dirname, '../../migrations/001-initial.sql');
8
- // Cache the WASM module so multiple instances don't re-initialize Emscripten
9
- let sqlJsPromise = null;
10
- function getSqlJs() {
11
- if (!sqlJsPromise)
12
- sqlJsPromise = initSqlJs();
13
- return sqlJsPromise;
14
- }
15
8
  export class SQLiteAdapter {
16
9
  db;
17
10
  dbPath;
@@ -21,72 +14,39 @@ export class SQLiteAdapter {
21
14
  async initialize() {
22
15
  const dir = dirname(this.dbPath);
23
16
  mkdirSync(dir, { recursive: true });
24
- const SQL = await getSqlJs();
25
- if (existsSync(this.dbPath)) {
26
- const fileBuffer = readFileSync(this.dbPath);
27
- this.db = new SQL.Database(new Uint8Array(fileBuffer));
28
- }
29
- else {
30
- this.db = new SQL.Database();
31
- }
32
- this.db.run('PRAGMA foreign_keys = ON');
33
- // sql.js uses db.export() which serializes the full DB — WAL mode is not
34
- // supported and causes data loss. Force DELETE journal mode.
35
- this.db.run('PRAGMA journal_mode = DELETE');
17
+ this.db = new Database(this.dbPath);
18
+ this.db.pragma('journal_mode = WAL');
19
+ this.db.pragma('foreign_keys = ON');
20
+ this.db.pragma('busy_timeout = 5000');
36
21
  // Run migration (multi-statement)
37
22
  const sql = readFileSync(MIGRATION_PATH, 'utf-8');
38
23
  this.db.exec(sql);
39
- this.persist();
24
+ // Backfill FTS5 if nodes exist but index is empty
25
+ this.backfillFts();
40
26
  }
41
27
  async close() {
42
- this.persist();
43
28
  this.db.close();
44
29
  }
45
- persist() {
46
- const data = this.db.export();
47
- const buffer = Buffer.from(data);
48
- writeFileSync(this.dbPath, buffer);
30
+ backfillFts() {
31
+ const ftsCount = this.db.prepare('SELECT COUNT(*) as cnt FROM nodes_fts').get().cnt;
32
+ const nodeCount = this.db.prepare('SELECT COUNT(*) as cnt FROM nodes').get().cnt;
33
+ if (ftsCount === 0 && nodeCount > 0) {
34
+ this.db.exec('INSERT INTO nodes_fts(rowid, content) SELECT rowid, content FROM nodes');
35
+ }
49
36
  }
50
37
  // ─── Helpers ──────────────────────────────────────────
51
- runSql(sql, params = []) {
52
- this.db.run(sql, params);
53
- this.persist();
38
+ run(sql, params = []) {
39
+ this.db.prepare(sql).run(...params);
54
40
  }
55
- getSingle(sql, params = []) {
56
- const stmt = this.db.prepare(sql);
57
- stmt.bind(params);
58
- if (stmt.step()) {
59
- const columns = stmt.getColumnNames();
60
- const values = stmt.get();
61
- stmt.free();
62
- const row = {};
63
- for (let i = 0; i < columns.length; i++) {
64
- row[columns[i]] = values[i];
65
- }
66
- return row;
67
- }
68
- stmt.free();
69
- return undefined;
41
+ get(sql, params = []) {
42
+ return this.db.prepare(sql).get(...params);
70
43
  }
71
- getAll(sql, params = []) {
72
- const stmt = this.db.prepare(sql);
73
- stmt.bind(params);
74
- const rows = [];
75
- while (stmt.step()) {
76
- const columns = stmt.getColumnNames();
77
- const values = stmt.get();
78
- const row = {};
79
- for (let i = 0; i < columns.length; i++) {
80
- row[columns[i]] = values[i];
81
- }
82
- rows.push(row);
83
- }
84
- stmt.free();
85
- return rows;
44
+ all(sql, params = []) {
45
+ return this.db.prepare(sql).all(...params);
86
46
  }
87
47
  // ─── Nodes ─────────────────────────────────────────────
88
48
  async insertNode(node) {
89
- this.runSql(`
49
+ this.run(`
90
50
  INSERT INTO nodes (id, type, content, session_id, project_id, metadata,
91
51
  created_at, updated_at, thought_number, total_thoughts, branch_id,
92
52
  is_revision, revises_thought)
@@ -100,7 +60,7 @@ export class SQLiteAdapter {
100
60
  ]);
101
61
  }
102
62
  async getNode(id) {
103
- const row = this.getSingle('SELECT * FROM nodes WHERE id = ?', [id]);
63
+ const row = this.get('SELECT * FROM nodes WHERE id = ?', [id]);
104
64
  return row ? this.rowToNode(row) : null;
105
65
  }
106
66
  async queryNodes(query) {
@@ -128,11 +88,11 @@ export class SQLiteAdapter {
128
88
  params.push(`%${query.query}%`);
129
89
  }
130
90
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
131
- const countRow = this.getSingle(`SELECT COUNT(*) as cnt FROM nodes ${where}`, params);
91
+ const countRow = this.get(`SELECT COUNT(*) as cnt FROM nodes ${where}`, params);
132
92
  const totalCount = countRow.cnt;
133
93
  const limit = query.limit ?? 20;
134
94
  const offset = query.offset ?? 0;
135
- const rows = this.getAll(`SELECT * FROM nodes ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...params, limit, offset]);
95
+ const rows = this.all(`SELECT * FROM nodes ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...params, limit, offset]);
136
96
  return {
137
97
  items: rows.map(r => this.rowToNode(r)),
138
98
  totalCount,
@@ -140,18 +100,27 @@ export class SQLiteAdapter {
140
100
  };
141
101
  }
142
102
  async searchContent(text, limit = 20) {
143
- const rows = this.getAll(`
144
- SELECT * FROM nodes
145
- WHERE content LIKE ?
146
- ORDER BY created_at DESC
147
- LIMIT ?
148
- `, [`%${text}%`, limit]);
149
- return rows.map(r => this.rowToNode(r));
103
+ try {
104
+ const rows = this.all(`
105
+ SELECT n.* FROM nodes n
106
+ WHERE n.rowid IN (SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?)
107
+ ORDER BY n.created_at DESC
108
+ LIMIT ?
109
+ `, [text, limit]);
110
+ return rows.map(r => this.rowToNode(r));
111
+ }
112
+ catch {
113
+ // Fallback for queries that aren't valid FTS5 syntax
114
+ const rows = this.all(`
115
+ SELECT * FROM nodes WHERE content LIKE ? ORDER BY created_at DESC LIMIT ?
116
+ `, [`%${text}%`, limit]);
117
+ return rows.map(r => this.rowToNode(r));
118
+ }
150
119
  }
151
120
  // ─── Edges ─────────────────────────────────────────────
152
121
  async insertEdge(edge) {
153
122
  try {
154
- this.runSql(`
123
+ this.run(`
155
124
  INSERT INTO edges (id, source_id, target_id, type, weight, reasoning, created_at)
156
125
  VALUES (?, ?, ?, ?, ?, ?, ?)
157
126
  `, [
@@ -172,19 +141,17 @@ export class SQLiteAdapter {
172
141
  ? 'SELECT * FROM edges WHERE source_id = ? AND type = ?'
173
142
  : 'SELECT * FROM edges WHERE source_id = ?';
174
143
  const params = type ? [nodeId, type] : [nodeId];
175
- const rows = this.getAll(sql, params);
176
- return rows.map(r => this.rowToEdge(r));
144
+ return this.all(sql, params).map(r => this.rowToEdge(r));
177
145
  }
178
146
  async getEdgesTo(nodeId, type) {
179
147
  const sql = type
180
148
  ? 'SELECT * FROM edges WHERE target_id = ? AND type = ?'
181
149
  : 'SELECT * FROM edges WHERE target_id = ?';
182
150
  const params = type ? [nodeId, type] : [nodeId];
183
- const rows = this.getAll(sql, params);
184
- return rows.map(r => this.rowToEdge(r));
151
+ return this.all(sql, params).map(r => this.rowToEdge(r));
185
152
  }
186
153
  async traverseEdges(startId, type, maxDepth) {
187
- const rows = this.getAll(`
154
+ const rows = this.all(`
188
155
  WITH RECURSIVE chain(id, depth) AS (
189
156
  SELECT target_id, 1
190
157
  FROM edges
@@ -203,7 +170,7 @@ export class SQLiteAdapter {
203
170
  }
204
171
  // ─── Sessions ──────────────────────────────────────────
205
172
  async insertSession(session) {
206
- this.runSql(`
173
+ this.run(`
207
174
  INSERT INTO sessions (id, project_id, project_path, description, started_at, last_active)
208
175
  VALUES (?, ?, ?, ?, ?, ?)
209
176
  `, [
@@ -212,7 +179,7 @@ export class SQLiteAdapter {
212
179
  ]);
213
180
  }
214
181
  async getSession(id) {
215
- const row = this.getSingle('SELECT * FROM sessions WHERE id = ?', [id]);
182
+ const row = this.get('SELECT * FROM sessions WHERE id = ?', [id]);
216
183
  return row ? this.rowToSession(row) : null;
217
184
  }
218
185
  async updateSession(id, fields) {
@@ -228,12 +195,12 @@ export class SQLiteAdapter {
228
195
  }
229
196
  if (sets.length > 0) {
230
197
  params.push(id);
231
- this.runSql(`UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`, params);
198
+ this.run(`UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`, params);
232
199
  }
233
200
  }
234
201
  // ─── Skill Registry ───────────────────────────────────
235
202
  async insertSkill(entry) {
236
- this.runSql(`
203
+ this.run(`
237
204
  INSERT OR IGNORE INTO skill_registry
238
205
  (id, plugin_name, skill_name, verb, invocation, areas, detects, produces, invokes, platform)
239
206
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -267,7 +234,7 @@ export class SQLiteAdapter {
267
234
  params.push(filter.platform);
268
235
  }
269
236
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
270
- const rows = this.getAll(`SELECT * FROM skill_registry ${where}`, params);
237
+ const rows = this.all(`SELECT * FROM skill_registry ${where}`, params);
271
238
  return rows.map(r => ({
272
239
  id: r.id,
273
240
  pluginName: r.plugin_name,
@@ -299,18 +266,18 @@ export class SQLiteAdapter {
299
266
  params.push(...types);
300
267
  }
301
268
  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
302
- const nodeRows = this.getAll(`SELECT * FROM nodes ${where}`, params);
269
+ const nodeRows = this.all(`SELECT * FROM nodes ${where}`, params);
303
270
  const nodes = nodeRows.map(r => this.rowToNode(r));
304
271
  let edges = [];
305
272
  if (opts?.includeEdges !== false) {
306
273
  const nodeIds = nodes.map(n => n.id);
307
274
  if (nodeIds.length > 0) {
308
275
  const placeholders = nodeIds.map(() => '?').join(',');
309
- const edgeRows = this.getAll(`SELECT * FROM edges WHERE source_id IN (${placeholders}) OR target_id IN (${placeholders})`, [...nodeIds, ...nodeIds]);
276
+ const edgeRows = this.all(`SELECT * FROM edges WHERE source_id IN (${placeholders}) OR target_id IN (${placeholders})`, [...nodeIds, ...nodeIds]);
310
277
  edges = edgeRows.map(r => this.rowToEdge(r));
311
278
  }
312
279
  }
313
- const sessionRows = this.getAll('SELECT * FROM sessions');
280
+ const sessionRows = this.all('SELECT * FROM sessions');
314
281
  return {
315
282
  exportedAt: new Date().toISOString(),
316
283
  nodeCount: nodes.length,
@@ -323,15 +290,15 @@ export class SQLiteAdapter {
323
290
  // ─── Stats ─────────────────────────────────────────────
324
291
  async getStats() {
325
292
  const nodesByType = {};
326
- const ntRows = this.getAll('SELECT type, COUNT(*) as cnt FROM nodes GROUP BY type');
293
+ const ntRows = this.all('SELECT type, COUNT(*) as cnt FROM nodes GROUP BY type');
327
294
  for (const r of ntRows)
328
295
  nodesByType[r.type] = r.cnt;
329
296
  const edgesByType = {};
330
- const etRows = this.getAll('SELECT type, COUNT(*) as cnt FROM edges GROUP BY type');
297
+ const etRows = this.all('SELECT type, COUNT(*) as cnt FROM edges GROUP BY type');
331
298
  for (const r of etRows)
332
299
  edgesByType[r.type] = r.cnt;
333
- const totalNodes = this.getSingle('SELECT COUNT(*) as cnt FROM nodes').cnt;
334
- const totalEdges = this.getSingle('SELECT COUNT(*) as cnt FROM edges').cnt;
300
+ const totalNodes = this.get('SELECT COUNT(*) as cnt FROM nodes').cnt;
301
+ const totalEdges = this.get('SELECT COUNT(*) as cnt FROM edges').cnt;
335
302
  return {
336
303
  totalNodes,
337
304
  totalEdges,
@@ -45,6 +45,13 @@ export declare class VaultBridge {
45
45
  * If a file with the same title already exists, appends a timestamp suffix.
46
46
  */
47
47
  write(opts: VaultWriteOpts): string;
48
+ /**
49
+ * Drop a sentinel at the vault root so the knowledge-graph indexer knows
50
+ * there are pending writes to flush on the next search. Best-effort: a
51
+ * failure here must not break the write path, since the consequence is
52
+ * just a stale `kg_search()` until the next manual `kg_index()`.
53
+ */
54
+ private markPendingIndex;
48
55
  /** Read a single note by relative path. */
49
56
  read(relPath: string): VaultNote | null;
50
57
  /**
@@ -126,8 +126,24 @@ export class VaultBridge {
126
126
  };
127
127
  const fileContent = matter.stringify(opts.content, fm);
128
128
  writeFileSync(absPath, fileContent, 'utf-8');
129
+ this.markPendingIndex();
129
130
  return relative(this.vaultRoot, absPath);
130
131
  }
132
+ /**
133
+ * Drop a sentinel at the vault root so the knowledge-graph indexer knows
134
+ * there are pending writes to flush on the next search. Best-effort: a
135
+ * failure here must not break the write path, since the consequence is
136
+ * just a stale `kg_search()` until the next manual `kg_index()`.
137
+ */
138
+ markPendingIndex() {
139
+ try {
140
+ const sentinel = join(this.vaultRoot, '.kg-pending-index');
141
+ writeFileSync(sentinel, new Date().toISOString(), 'utf-8');
142
+ }
143
+ catch {
144
+ // Non-fatal — manual `kg_index()` still works as a backstop.
145
+ }
146
+ }
131
147
  // ─── Read ───────────────────────────────────────────
132
148
  /** Read a single note by relative path. */
133
149
  read(relPath) {
@@ -84,3 +84,24 @@ CREATE TABLE IF NOT EXISTS skill_registry (
84
84
 
85
85
  UNIQUE(plugin_name, skill_name)
86
86
  );
87
+
88
+ -- ─── Full-Text Search ────────────────────────────────────
89
+
90
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
91
+ content,
92
+ content='nodes',
93
+ content_rowid='rowid'
94
+ );
95
+
96
+ CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
97
+ INSERT INTO nodes_fts(rowid, content) VALUES (new.rowid, new.content);
98
+ END;
99
+
100
+ CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
101
+ INSERT INTO nodes_fts(nodes_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
102
+ INSERT INTO nodes_fts(rowid, content) VALUES (new.rowid, new.content);
103
+ END;
104
+
105
+ CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
106
+ INSERT INTO nodes_fts(nodes_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
107
+ END;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feelingmindful/thinking-graph",
3
- "version": "1.12.0",
3
+ "version": "1.14.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,12 +31,13 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@modelcontextprotocol/sdk": "^1.12.0",
34
+ "better-sqlite3": "^12.8.0",
34
35
  "gray-matter": "^4.0.3",
35
- "sql.js": "^1.12.0",
36
36
  "uuid": "^10.0.0",
37
37
  "zod": "^3.23.0"
38
38
  },
39
39
  "devDependencies": {
40
+ "@types/better-sqlite3": "^7.6.13",
40
41
  "@types/node": "^25.5.0",
41
42
  "@types/uuid": "^10.0.0",
42
43
  "typescript": "^5.5.0",