@feelingmindful/thinking-graph 1.15.1 → 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,
@@ -65,6 +65,18 @@ function searchFlags(input, recencyDefault) {
65
65
  }
66
66
  return flags.length ? ` ${flags.join(' ')}` : '';
67
67
  }
68
+ // `parallel-cli research run` has no recency/domain flags (unlike `search`), so
69
+ // fold those constraints into the prompt text to avoid silently dropping them.
70
+ function withResearchConstraints(query, input) {
71
+ const parts = [];
72
+ const afterDate = afterDateFor(input.recencyFilter);
73
+ if (afterDate)
74
+ parts.push(`Only use sources published on or after ${afterDate}.`);
75
+ if (input.domainFilter?.length) {
76
+ parts.push(`Restrict sources to these domains: ${input.domainFilter.join(', ')}.`);
77
+ }
78
+ return parts.length ? `${query} ${parts.join(' ')}` : query;
79
+ }
68
80
  function buildGroundedSteps(input) {
69
81
  const steps = [];
70
82
  const query = input.query;
@@ -103,8 +115,16 @@ function buildActionPlan(input) {
103
115
  if (shouldPrependGrounded) {
104
116
  steps.push(...buildGroundedSteps(input));
105
117
  }
106
- // grounded_qa is NotebookLM-only no web fallback steps.
118
+ // grounded_qa prefers NotebookLM, but must still work when NotebookLM isn't
119
+ // installed or has no matching notebook — append a web-grounded fallback.
107
120
  if (input.intent === 'grounded_qa') {
121
+ steps.push({
122
+ tool: 'Bash',
123
+ description: 'Fallback (use only if NotebookLM is unavailable or returned no matching notebook): cited answer via parallel-cli research run',
124
+ args: {
125
+ command: `parallel-cli research run --text ${shq(withResearchConstraints(query, input))} --processor pro -o .premium/research/research-grounded`,
126
+ },
127
+ });
108
128
  return steps;
109
129
  }
110
130
  // Choose the parallel-cli command based on intent.
@@ -124,7 +144,7 @@ function buildActionPlan(input) {
124
144
  tool: 'Bash',
125
145
  description: 'Step-by-step comparison with web grounding via parallel-cli research run',
126
146
  args: {
127
- command: `parallel-cli research run --text ${shq(query)} --text-description ${shq('step-by-step reasoning')} --processor pro -o .premium/research/research-compare`,
147
+ command: `parallel-cli research run --text ${shq(withResearchConstraints(query, input))} --text-description ${shq('step-by-step reasoning')} --processor pro -o .premium/research/research-compare`,
128
148
  },
129
149
  });
130
150
  break;
@@ -133,7 +153,7 @@ function buildActionPlan(input) {
133
153
  tool: 'Bash',
134
154
  description: 'Deep multi-source research (30s+) via parallel-cli research run',
135
155
  args: {
136
- command: `parallel-cli research run --text ${shq(query)} --processor pro -o .premium/research/research-explore`,
156
+ command: `parallel-cli research run --text ${shq(withResearchConstraints(query, input))} --processor pro -o .premium/research/research-explore`,
137
157
  },
138
158
  });
139
159
  break;
@@ -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.1",
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",