@feelingmindful/thinking-graph 1.15.2 → 1.20.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.
@@ -1,5 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import { NODE_TYPES, EDGE_TYPES } from '../engine/types.js';
3
+ import { classifyIntent } from '../engine/intent.js';
4
+ import { config } from '../config.js';
5
+ // Hybrid (semantic + lexical + recency) recall is on by default; set
6
+ // THINKING_GRAPH_HYBRID_RECALL=false to force the legacy substring path.
7
+ const HYBRID_RECALL_ENABLED = process.env.THINKING_GRAPH_HYBRID_RECALL !== 'false';
8
+ // Graph expansion (multi-hop recall) is on by default; set
9
+ // THINKING_GRAPH_GRAPH_EXPAND=false to disable the 1-hop typed-edge walk.
10
+ const GRAPH_EXPAND_ENABLED = process.env.THINKING_GRAPH_GRAPH_EXPAND !== 'false';
3
11
  const coerceBool = z.preprocess((v) => (v === 'true' ? true : v === 'false' ? false : v), z.boolean());
4
12
  export const recallSchema = z.object({
5
13
  query: z.string().optional().describe('Full-text search'),
@@ -32,23 +40,63 @@ export async function recallHandler(graph, input, vault, projectSlug) {
32
40
  nodes: enriched.filter(Boolean),
33
41
  totalCount: nodes.length,
34
42
  hasMore: nodes.length > (input.limit ?? 20),
43
+ intent: classifyIntent(input.query ?? ''),
35
44
  }),
36
45
  }],
37
46
  };
38
47
  }
39
- // Standard query search SQLite graph
40
- const result = await graph.findNodes({
41
- query: input.query,
42
- type: input.type,
43
- sessionId: input.sessionId,
44
- projectId: input.projectId,
45
- crossProject: input.crossProject,
46
- since: input.since,
47
- metadata: input.metadata,
48
- limit: input.limit,
49
- offset: input.offset,
50
- });
51
- const enriched = await Promise.all(result.items.map(n => graph.getNodeWithEdges(n.id)));
48
+ // Standard query. Use ranked hybrid recall (semantic + lexical + recency)
49
+ // when there's a text query and no metadata filter (hybrid does not filter on
50
+ // metadata — fall back to the substring path to preserve that semantics).
51
+ const useHybrid = HYBRID_RECALL_ENABLED && !!input.query && !input.metadata;
52
+ const result = useHybrid
53
+ ? await graph.recallHybrid({
54
+ query: input.query,
55
+ type: input.type,
56
+ sessionId: input.sessionId,
57
+ projectId: input.projectId,
58
+ crossProject: input.crossProject,
59
+ since: input.since,
60
+ limit: input.limit,
61
+ offset: input.offset,
62
+ })
63
+ : await graph.findNodes({
64
+ query: input.query,
65
+ type: input.type,
66
+ sessionId: input.sessionId,
67
+ projectId: input.projectId,
68
+ crossProject: input.crossProject,
69
+ since: input.since,
70
+ metadata: input.metadata,
71
+ limit: input.limit,
72
+ offset: input.offset,
73
+ });
74
+ // Enrich with edges; carry the fused score through when present (hybrid path).
75
+ const enriched = await Promise.all(result.items.map(async (n) => {
76
+ const withEdges = await graph.getNodeWithEdges(n.id);
77
+ if (!withEdges)
78
+ return null;
79
+ const score = n.score;
80
+ return score === undefined ? withEdges : { ...withEdges, score };
81
+ }));
82
+ const enrichedNodes = enriched.filter((n) => n !== null);
83
+ // Graph expansion (gated). For relationship/causation queries, pull in 1-hop
84
+ // typed-edge neighbours of the top content hits as structural context. Plain
85
+ // single-entity lookups skip this so they stay clean and cheap.
86
+ const intent = classifyIntent(input.query ?? '');
87
+ const related = [];
88
+ if (useHybrid && GRAPH_EXPAND_ENABLED && intent.multiHop && enrichedNodes.length > 0) {
89
+ const present = new Set(enrichedNodes.map(n => n.id));
90
+ const seedIds = enrichedNodes.slice(0, config.graphSeedCount).map(n => n.id);
91
+ const neighbors = await graph.expandNeighbors(seedIds, config.graphExpandLimit);
92
+ for (const nb of neighbors) {
93
+ if (present.has(nb.id))
94
+ continue;
95
+ const withEdges = await graph.getNodeWithEdges(nb.id);
96
+ if (withEdges)
97
+ related.push({ ...withEdges, graphExpanded: true });
98
+ }
99
+ }
52
100
  // Also search Obsidian vault if query text is provided
53
101
  let vaultResults = [];
54
102
  if (vault && projectSlug && input.query) {
@@ -64,9 +112,14 @@ export async function recallHandler(graph, input, vault, projectSlug) {
64
112
  content: [{
65
113
  type: 'text',
66
114
  text: JSON.stringify({
67
- nodes: enriched.filter(Boolean),
115
+ nodes: enrichedNodes,
68
116
  totalCount: result.totalCount,
69
117
  hasMore: result.hasMore,
118
+ intent,
119
+ ...(related.length > 0 && {
120
+ related,
121
+ relatedCount: related.length,
122
+ }),
70
123
  ...(vaultResults.length > 0 && {
71
124
  vault: vaultResults,
72
125
  vaultCount: vaultResults.length,
@@ -28,8 +28,8 @@ export declare const thinkSchema: z.ZodObject<{
28
28
  branchId: z.ZodOptional<z.ZodString>;
29
29
  needsMoreThoughts: z.ZodOptional<z.ZodEffects<z.ZodBoolean, boolean, unknown>>;
30
30
  }, "strip", z.ZodTypeAny, {
31
- thought: string;
32
31
  type: "thought" | "decision" | "insight" | "code_fact" | "assumption" | "detection" | "tech_debt" | "principle" | "pattern" | "skill_result" | "research";
32
+ thought: string;
33
33
  thoughtNumber: number;
34
34
  totalThoughts: number;
35
35
  nextThoughtNeeded: boolean;
@@ -46,10 +46,11 @@ export declare class VaultBridge {
46
46
  */
47
47
  write(opts: VaultWriteOpts): string;
48
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()`.
49
+ * Drop a sentinel at the vault root so the knowledge-graph indexer flushes
50
+ * these pending writes on the next `kg_search()` — it auto-indexes
51
+ * (incrementally) when the sentinel is present, so search is never stale.
52
+ * Best-effort: a failure here must not break the write path; it only delays
53
+ * freshness until a later write re-drops the sentinel or `kg_index()` runs.
53
54
  */
54
55
  private markPendingIndex;
55
56
  /** Read a single note by relative path. */
@@ -130,10 +130,11 @@ export class VaultBridge {
130
130
  return relative(this.vaultRoot, absPath);
131
131
  }
132
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()`.
133
+ * Drop a sentinel at the vault root so the knowledge-graph indexer flushes
134
+ * these pending writes on the next `kg_search()` — it auto-indexes
135
+ * (incrementally) when the sentinel is present, so search is never stale.
136
+ * Best-effort: a failure here must not break the write path; it only delays
137
+ * freshness until a later write re-drops the sentinel or `kg_index()` runs.
137
138
  */
138
139
  markPendingIndex() {
139
140
  try {
@@ -141,7 +142,8 @@ export class VaultBridge {
141
142
  writeFileSync(sentinel, new Date().toISOString(), 'utf-8');
142
143
  }
143
144
  catch {
144
- // Non-fatal — manual `kg_index()` still works as a backstop.
145
+ // Non-fatal — a later write re-drops the sentinel; `kg_index()` is a
146
+ // manual backstop.
145
147
  }
146
148
  }
147
149
  // ─── Read ───────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feelingmindful/thinking-graph",
3
- "version": "1.15.2",
3
+ "version": "1.20.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",