gnosys 5.12.2 → 5.12.3

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
@@ -1752,10 +1752,17 @@ regTool("gnosys_hybrid_search", "Search memories using hybrid keyword + semantic
1752
1752
  };
1753
1753
  }
1754
1754
  try {
1755
- const results = await hybridSearch.hybridSearch(query, limit || 15, mode || "hybrid");
1755
+ const requestedMode = mode || "hybrid";
1756
+ const results = await hybridSearch.hybridSearch(query, limit || 15, requestedMode);
1757
+ // v5.12.3: hybrid used to degrade to keyword-only silently when the
1758
+ // semantic leg can't run (embeddings are only built by gnosys_reindex,
1759
+ // and DB mode also needs the store-local query embedder). Say so loudly.
1760
+ const degradeWarning = requestedMode !== "keyword" && !hybridSearch.canRunSemantic()
1761
+ ? `⚠️ Semantic embeddings unavailable — ${requestedMode} search ran keyword-only. Run gnosys_reindex to build embeddings and enable semantic recall.\n\n`
1762
+ : "";
1756
1763
  if (results.length === 0) {
1757
1764
  return {
1758
- content: [{ type: "text", text: `No results for "${query}". Try gnosys_reindex to build embeddings, or different keywords.` }],
1765
+ content: [{ type: "text", text: `${degradeWarning}No results for "${query}". Try different keywords.` }],
1759
1766
  };
1760
1767
  }
1761
1768
  const formatted = results
@@ -1774,7 +1781,7 @@ regTool("gnosys_hybrid_search", "Search memories using hybrid keyword + semantic
1774
1781
  content: [
1775
1782
  {
1776
1783
  type: "text",
1777
- text: `Found ${results.length} results for "${query}" (${embCount} embeddings indexed):\n\n${formatted}`,
1784
+ text: `${degradeWarning}Found ${results.length} results for "${query}" (${embCount} embeddings indexed):\n\n${formatted}`,
1778
1785
  },
1779
1786
  ],
1780
1787
  };
@@ -1802,10 +1809,19 @@ regTool("gnosys_semantic_search", "Search memories using semantic similarity onl
1802
1809
  };
1803
1810
  }
1804
1811
  try {
1812
+ // v5.12.3: semantic search without a working semantic leg either
1813
+ // returns nothing or (in DB mode) keyword hits mislabeled as semantic —
1814
+ // refuse with the exact reason instead.
1815
+ if (!hybridSearch.canRunSemantic()) {
1816
+ return {
1817
+ content: [{ type: "text", text: `⚠️ Semantic embeddings unavailable — semantic search cannot run. Run gnosys_reindex to build embeddings, then retry.` }],
1818
+ isError: true,
1819
+ };
1820
+ }
1805
1821
  const results = await hybridSearch.hybridSearch(query, limit || 15, "semantic");
1806
1822
  if (results.length === 0) {
1807
1823
  return {
1808
- content: [{ type: "text", text: `No semantic results for "${query}". Run gnosys_reindex first to build embeddings.` }],
1824
+ content: [{ type: "text", text: `No semantic results for "${query}". Try a broader query.` }],
1809
1825
  };
1810
1826
  }
1811
1827
  const formatted = results
@@ -23,6 +23,7 @@ import { statSync } from "fs";
23
23
  import { syncMemoryToDb, syncDearchiveToDb } from "./dbWrite.js";
24
24
  import { enableWAL } from "./lock.js";
25
25
  import { auditLog } from "./audit.js";
26
+ import { ftsTerms, ftsAndQuery, ftsOrQuery } from "./ftsQuery.js";
26
27
  // ─── Archive Manager ────────────────────────────────────────────────────
27
28
  export class GnosysArchive {
28
29
  db = null;
@@ -217,8 +218,8 @@ export class GnosysArchive {
217
218
  searchArchive(query, limit = 20) {
218
219
  if (!this.db)
219
220
  return [];
220
- const safeQuery = query.replace(/['"]/g, "").trim();
221
- if (!safeQuery)
221
+ const terms = ftsTerms(query);
222
+ if (terms.length === 0)
222
223
  return [];
223
224
  const stmt = this.db.prepare(`
224
225
  SELECT
@@ -235,7 +236,12 @@ export class GnosysArchive {
235
236
  LIMIT ?
236
237
  `);
237
238
  try {
238
- return stmt.all(safeQuery, limit);
239
+ // v5.12.3: AND first (precision), OR retry when AND finds nothing —
240
+ // multi-word queries previously required every term to match.
241
+ const results = stmt.all(ftsAndQuery(terms), limit);
242
+ if (results.length > 0 || terms.length === 1)
243
+ return results;
244
+ return stmt.all(ftsOrQuery(terms), limit);
239
245
  }
240
246
  catch {
241
247
  // FTS5 query syntax failed — try LIKE fallback
@@ -251,7 +257,7 @@ export class GnosysArchive {
251
257
  WHERE content LIKE ? OR title LIKE ? OR tags LIKE ?
252
258
  LIMIT ?
253
259
  `);
254
- const pattern = `%${safeQuery}%`;
260
+ const pattern = `%${terms.join(" ")}%`;
255
261
  return likeStmt.all(pattern, pattern, pattern, limit);
256
262
  }
257
263
  }
package/dist/lib/db.js CHANGED
@@ -21,6 +21,7 @@ import { enableWAL } from "./lock.js";
21
21
  import { getGnosysHome as getGnosysHomeImpl, getCentralDbPath as getCentralDbPathImpl } from "./paths.js";
22
22
  import { readMachineConfig } from "./machineConfig.js";
23
23
  import { logError } from "./log.js";
24
+ import { ftsTerms, ftsAndQuery, ftsOrQuery } from "./ftsQuery.js";
24
25
  import { ulid } from "ulidx";
25
26
  // ─── Schema ─────────────────────────────────────────────────────────────
26
27
  const SCHEMA_VERSION = 5;
@@ -1088,27 +1089,33 @@ export class GnosysDB {
1088
1089
  }
1089
1090
  // ─── FTS5 Search ────────────────────────────────────────────────────
1090
1091
  searchFts(query, limit = 20) {
1091
- const safeQuery = query.replace(/['"]/g, "").trim();
1092
- if (!safeQuery)
1092
+ const terms = ftsTerms(query);
1093
+ if (terms.length === 0)
1093
1094
  return [];
1094
1095
  // v5.8.0 (#7): join memories so callers can render project-prefixed IDs.
1095
1096
  return this.withRecovery(() => {
1096
1097
  try {
1097
- return this.prep(`
1098
- SELECT m.id AS id, m.title AS title,
1099
- snippet(memories_fts, 5, '>>>', '<<<', '...', 40) as snippet,
1100
- fts.rank AS rank,
1101
- m.project_id AS project_id
1102
- FROM memories_fts fts
1103
- JOIN memories m ON m.id = fts.id
1104
- WHERE memories_fts MATCH ?
1105
- ORDER BY fts.rank
1106
- LIMIT ?
1107
- `).all(safeQuery, limit);
1098
+ const run = (match) => this.prep(`
1099
+ SELECT m.id AS id, m.title AS title,
1100
+ snippet(memories_fts, 5, '>>>', '<<<', '...', 40) as snippet,
1101
+ fts.rank AS rank,
1102
+ m.project_id AS project_id
1103
+ FROM memories_fts fts
1104
+ JOIN memories m ON m.id = fts.id
1105
+ WHERE memories_fts MATCH ?
1106
+ ORDER BY fts.rank
1107
+ LIMIT ?
1108
+ `).all(match, limit);
1109
+ // v5.12.3: AND first (precision), OR retry when AND finds nothing —
1110
+ // multi-word queries previously required every term to match.
1111
+ const results = run(ftsAndQuery(terms));
1112
+ if (results.length > 0 || terms.length === 1)
1113
+ return results;
1114
+ return run(ftsOrQuery(terms));
1108
1115
  }
1109
1116
  catch {
1110
1117
  // FTS5 syntax error — fallback to LIKE
1111
- const pattern = `%${safeQuery}%`;
1118
+ const pattern = `%${terms.join(" ")}%`;
1112
1119
  return this.prep(`
1113
1120
  SELECT id, title, substr(content, 1, 200) as snippet, 0 as rank, project_id
1114
1121
  FROM memories WHERE content LIKE ? OR title LIKE ? OR tags LIKE ?
@@ -1118,8 +1125,8 @@ export class GnosysDB {
1118
1125
  });
1119
1126
  }
1120
1127
  discoverFts(query, limit = 20) {
1121
- const safeQuery = query.replace(/['"]/g, "").trim();
1122
- if (!safeQuery)
1128
+ const terms = ftsTerms(query);
1129
+ if (terms.length === 0)
1123
1130
  return [];
1124
1131
  // v5.7.1 (#14): join `memories` so callers can render project-prefixed IDs.
1125
1132
  const select = `
@@ -1130,26 +1137,37 @@ export class GnosysDB {
1130
1137
  ORDER BY fts.rank
1131
1138
  LIMIT ?
1132
1139
  `;
1133
- return this.withRecovery(() => {
1140
+ // Let corruption escape to withRecovery (reopen + retry); plain FTS
1141
+ // syntax failures still degrade gracefully to "no results".
1142
+ const tryRun = (match) => {
1134
1143
  try {
1135
- const colQuery = `{relevance title tags} : ${safeQuery}`;
1136
- const results = this.prep(select).all(colQuery, limit);
1137
- if (results.length > 0)
1138
- return results;
1139
- return this.prep(select).all(safeQuery, limit);
1144
+ return this.prep(select).all(match, limit);
1140
1145
  }
1141
- catch {
1142
- try {
1143
- return this.prep(select).all(safeQuery, limit);
1144
- }
1145
- catch (err) {
1146
- // Let corruption escape to withRecovery (reopen + retry); plain FTS
1147
- // syntax failures still degrade gracefully to "no results".
1148
- if (GnosysDB.isCorruptionError(err))
1149
- throw err;
1150
- return [];
1151
- }
1146
+ catch (err) {
1147
+ if (GnosysDB.isCorruptionError(err))
1148
+ throw err;
1149
+ return [];
1152
1150
  }
1151
+ };
1152
+ // v5.12.3: precision-to-recall ladder. AND on the metadata columns,
1153
+ // AND anywhere, then OR retries — multi-word queries previously
1154
+ // required every term to match, so long queries returned nothing.
1155
+ // Parens scope the column filter to the whole expression (a bare
1156
+ // `{cols} : a b` only filtered the first term).
1157
+ const colScoped = (expr) => `{relevance title tags} : (${expr})`;
1158
+ const andExpr = ftsAndQuery(terms);
1159
+ return this.withRecovery(() => {
1160
+ let results = tryRun(colScoped(andExpr));
1161
+ if (results.length > 0)
1162
+ return results;
1163
+ results = tryRun(andExpr);
1164
+ if (results.length > 0 || terms.length === 1)
1165
+ return results;
1166
+ const orExpr = ftsOrQuery(terms);
1167
+ results = tryRun(colScoped(orExpr));
1168
+ if (results.length > 0)
1169
+ return results;
1170
+ return tryRun(orExpr);
1153
1171
  });
1154
1172
  }
1155
1173
  // ─── Relationships ──────────────────────────────────────────────────
@@ -0,0 +1,25 @@
1
+ /**
2
+ * FTS5 MATCH query construction — shared by the central-DB search (db.ts),
3
+ * the per-store search index (search.ts), and archive search (archive.ts).
4
+ *
5
+ * FTS5 treats space-separated bare terms as implicit AND, so the long
6
+ * descriptive queries the tool docs encourage ("auth JWT session tokens
7
+ * refresh") return zero results unless EVERY term matches. Callers use
8
+ * these helpers to try AND first (precision), then retry with OR
9
+ * (recall) when AND finds nothing — BM25 still ranks the best-covered
10
+ * memories first in the OR pass.
11
+ *
12
+ * Every term is emitted as a quoted phrase, which also makes previously
13
+ * syntax-error-prone input (hyphens, colons, FTS5 keywords like NOT)
14
+ * safe. A trailing `*` is preserved as an FTS5 prefix query (`"term"*`).
15
+ */
16
+ /**
17
+ * Split a raw query into sanitized terms. Drops quote characters and any
18
+ * token with no letters or digits (pure punctuation can't match anything
19
+ * under the unicode61 tokenizer).
20
+ */
21
+ export declare function ftsTerms(query: string): string[];
22
+ /** Implicit-AND MATCH expression: all terms must match. */
23
+ export declare function ftsAndQuery(terms: string[]): string;
24
+ /** OR MATCH expression: any term may match; BM25 ranks fuller matches higher. */
25
+ export declare function ftsOrQuery(terms: string[]): string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * FTS5 MATCH query construction — shared by the central-DB search (db.ts),
3
+ * the per-store search index (search.ts), and archive search (archive.ts).
4
+ *
5
+ * FTS5 treats space-separated bare terms as implicit AND, so the long
6
+ * descriptive queries the tool docs encourage ("auth JWT session tokens
7
+ * refresh") return zero results unless EVERY term matches. Callers use
8
+ * these helpers to try AND first (precision), then retry with OR
9
+ * (recall) when AND finds nothing — BM25 still ranks the best-covered
10
+ * memories first in the OR pass.
11
+ *
12
+ * Every term is emitted as a quoted phrase, which also makes previously
13
+ * syntax-error-prone input (hyphens, colons, FTS5 keywords like NOT)
14
+ * safe. A trailing `*` is preserved as an FTS5 prefix query (`"term"*`).
15
+ */
16
+ /**
17
+ * Split a raw query into sanitized terms. Drops quote characters and any
18
+ * token with no letters or digits (pure punctuation can't match anything
19
+ * under the unicode61 tokenizer).
20
+ */
21
+ export function ftsTerms(query) {
22
+ return query
23
+ .replace(/['"]/g, "")
24
+ .split(/\s+/)
25
+ .filter((t) => /[\p{L}\p{N}]/u.test(t));
26
+ }
27
+ /** Render one term as a safe FTS5 phrase, preserving trailing-`*` prefix queries. */
28
+ function ftsPhrase(term) {
29
+ const prefixMatch = term.match(/^(.*?)\*+$/);
30
+ if (prefixMatch && /[\p{L}\p{N}]/u.test(prefixMatch[1])) {
31
+ return `"${prefixMatch[1]}"*`;
32
+ }
33
+ return `"${term}"`;
34
+ }
35
+ /** Implicit-AND MATCH expression: all terms must match. */
36
+ export function ftsAndQuery(terms) {
37
+ return terms.map(ftsPhrase).join(" ");
38
+ }
39
+ /** OR MATCH expression: any term may match; BM25 ranks fuller matches higher. */
40
+ export function ftsOrQuery(terms) {
41
+ return terms.map(ftsPhrase).join(" OR ");
42
+ }
@@ -57,6 +57,15 @@ export declare class GnosysHybridSearch {
57
57
  * Check if embeddings are available.
58
58
  */
59
59
  hasEmbeddings(): boolean;
60
+ /**
61
+ * True when hybrid/semantic search can actually run its semantic leg.
62
+ * In DB mode this requires BOTH stored vectors in the central DB and the
63
+ * store-local embeddings.db used to embed the query text — the central
64
+ * count alone can be non-zero on a machine that only syncs gnosys.db,
65
+ * in which case hybridSearch() silently runs keyword-only (embedQuery
66
+ * is never constructed).
67
+ */
68
+ canRunSemantic(): boolean;
60
69
  /**
61
70
  * Get embedding count.
62
71
  */
@@ -284,6 +284,20 @@ export class GnosysHybridSearch {
284
284
  return this.dbSearch.hasEmbeddings();
285
285
  return this.embeddings.hasEmbeddings();
286
286
  }
287
+ /**
288
+ * True when hybrid/semantic search can actually run its semantic leg.
289
+ * In DB mode this requires BOTH stored vectors in the central DB and the
290
+ * store-local embeddings.db used to embed the query text — the central
291
+ * count alone can be non-zero on a machine that only syncs gnosys.db,
292
+ * in which case hybridSearch() silently runs keyword-only (embedQuery
293
+ * is never constructed).
294
+ */
295
+ canRunSemantic() {
296
+ if (this.dbSearch) {
297
+ return this.dbSearch.hasEmbeddings() && this.embeddings.hasEmbeddings();
298
+ }
299
+ return this.embeddings.hasEmbeddings();
300
+ }
287
301
  /**
288
302
  * Get embedding count.
289
303
  */
@@ -67,6 +67,11 @@ export async function runHybridSearchCommand(getResolver, query, opts) {
67
67
  const embeddings = new GnosysEmbeddings(storePath);
68
68
  const hybridSearch = new GnosysHybridSearch(search, embeddings, resolver, storePath);
69
69
  const mode = opts.mode;
70
+ // v5.12.3: hybrid used to degrade to keyword-only silently when the
71
+ // semantic leg can't run. Warn on stderr so --json stdout stays clean.
72
+ if (mode !== "keyword" && !hybridSearch.canRunSemantic()) {
73
+ console.error(`⚠ Semantic embeddings unavailable — ${mode} search will run keyword-only. Run 'gnosys reindex' to build embeddings.`);
74
+ }
70
75
  const results = await hybridSearch.hybridSearch(query, parseInt(opts.limit, 10), mode);
71
76
  if (results.length === 0) {
72
77
  outputResult(!!opts.json, { query, mode, results: [] }, () => {
@@ -46,6 +46,23 @@ export declare class GnosysResolver {
46
46
  * Discover and initialize all store layers.
47
47
  */
48
48
  resolve(): Promise<ResolvedStore[]>;
49
+ /**
50
+ * Load the env-configured store tiers (optional `GNOSYS_STORES`, personal
51
+ * `GNOSYS_PERSONAL`, global `GNOSYS_GLOBAL`) onto `this.stores`.
52
+ *
53
+ * Shared by {@link resolve} and {@link resolveForProject}: a per-tool
54
+ * `projectRoot` call must STILL see personal/global, otherwise cross-project
55
+ * and global memories are unreachable — you couldn't write `store: "global"`
56
+ * / `"personal"` nor read them back from another project. Since every tool
57
+ * is told to always pass `projectRoot`, omitting these here silently
58
+ * disabled the entire cross-project tier system.
59
+ *
60
+ * Global is loaded whenever `GNOSYS_GLOBAL` is set, creating its directory on
61
+ * demand (parity with personal) so a configured-but-empty global store still
62
+ * works. It remains write-only-when-explicitly-targeted: {@link getWriteTarget}
63
+ * never auto-selects it.
64
+ */
65
+ private loadEnvTiers;
49
66
  /**
50
67
  * Get all stores in precedence order.
51
68
  */
@@ -79,8 +79,13 @@ export class GnosysResolver {
79
79
  }
80
80
  }
81
81
  catch {
82
- // Store doesn't exist at projectRoot — fall through to empty resolver
82
+ // No project store at projectRoot — still load the env tiers below.
83
83
  }
84
+ // Even when scoped to a projectRoot, load the personal/global/optional
85
+ // tiers so cross-project + global memories stay reachable (write store:
86
+ // "global"/"personal", and read them back from any project). Without this,
87
+ // the universal "always pass projectRoot" guidance silently disabled them.
88
+ await resolver.loadEnvTiers();
84
89
  return resolver;
85
90
  }
86
91
  /**
@@ -104,7 +109,30 @@ export class GnosysResolver {
104
109
  path: projectPath,
105
110
  });
106
111
  }
107
- // 2. Optional stores (GNOSYS_STORES colon-separated)
112
+ // 2-4. Env-configured tiers (optional, personal, global). Shared with
113
+ // resolveForProject so passing a projectRoot does NOT strip the
114
+ // cross-project tiers.
115
+ await this.loadEnvTiers();
116
+ return this.stores;
117
+ }
118
+ /**
119
+ * Load the env-configured store tiers (optional `GNOSYS_STORES`, personal
120
+ * `GNOSYS_PERSONAL`, global `GNOSYS_GLOBAL`) onto `this.stores`.
121
+ *
122
+ * Shared by {@link resolve} and {@link resolveForProject}: a per-tool
123
+ * `projectRoot` call must STILL see personal/global, otherwise cross-project
124
+ * and global memories are unreachable — you couldn't write `store: "global"`
125
+ * / `"personal"` nor read them back from another project. Since every tool
126
+ * is told to always pass `projectRoot`, omitting these here silently
127
+ * disabled the entire cross-project tier system.
128
+ *
129
+ * Global is loaded whenever `GNOSYS_GLOBAL` is set, creating its directory on
130
+ * demand (parity with personal) so a configured-but-empty global store still
131
+ * works. It remains write-only-when-explicitly-targeted: {@link getWriteTarget}
132
+ * never auto-selects it.
133
+ */
134
+ async loadEnvTiers() {
135
+ // Optional stores (GNOSYS_STORES — colon-separated, read-only)
108
136
  const optionalPaths = process.env.GNOSYS_STORES;
109
137
  if (optionalPaths) {
110
138
  const paths = optionalPaths.split(":").filter(Boolean);
@@ -124,7 +152,7 @@ export class GnosysResolver {
124
152
  }
125
153
  }
126
154
  }
127
- // 3. Personal store (GNOSYS_PERSONAL)
155
+ // Personal store (GNOSYS_PERSONAL — writable fallback target)
128
156
  const personalPath = process.env.GNOSYS_PERSONAL;
129
157
  if (personalPath) {
130
158
  const p = path.resolve(personalPath);
@@ -138,24 +166,22 @@ export class GnosysResolver {
138
166
  path: p,
139
167
  });
140
168
  }
141
- // 4. Global store (GNOSYS_GLOBAL)
142
- // Writable, but only when explicitly targeted never auto-selected.
169
+ // Global store (GNOSYS_GLOBAL — writable, but never auto-selected). Loaded
170
+ // whenever set; store.init() creates the dir on demand (parity with
171
+ // personal), so a configured-but-empty GNOSYS_GLOBAL still works.
143
172
  const globalPath = process.env.GNOSYS_GLOBAL;
144
173
  if (globalPath) {
145
174
  const p = path.resolve(globalPath);
146
- if (await this.isValidStore(p)) {
147
- const store = new GnosysStore(p);
148
- await store.init();
149
- this.stores.push({
150
- layer: "global",
151
- label: "global",
152
- store,
153
- writable: true,
154
- path: p,
155
- });
156
- }
175
+ const store = new GnosysStore(p);
176
+ await store.init();
177
+ this.stores.push({
178
+ layer: "global",
179
+ label: "global",
180
+ store,
181
+ writable: true,
182
+ path: p,
183
+ });
157
184
  }
158
- return this.stores;
159
185
  }
160
186
  /**
161
187
  * Get all stores in precedence order.
@@ -11,6 +11,7 @@ catch {
11
11
  // better-sqlite3 native module not available — search degrades gracefully
12
12
  }
13
13
  import path from "path";
14
+ import { ftsTerms, ftsAndQuery, ftsOrQuery } from "./ftsQuery.js";
14
15
  export class GnosysSearch {
15
16
  db = null;
16
17
  constructor(storePath) {
@@ -133,9 +134,8 @@ export class GnosysSearch {
133
134
  search(query, limit = 20) {
134
135
  if (!this.db)
135
136
  return [];
136
- // FTS5 query — escape special characters
137
- const safeQuery = query.replace(/['"]/g, "").trim();
138
- if (!safeQuery)
137
+ const terms = ftsTerms(query);
138
+ if (terms.length === 0)
139
139
  return [];
140
140
  const stmt = this.db.prepare(`
141
141
  SELECT
@@ -149,7 +149,12 @@ export class GnosysSearch {
149
149
  LIMIT ?
150
150
  `);
151
151
  try {
152
- return stmt.all(safeQuery, limit);
152
+ // v5.12.3: AND first (precision), OR retry when AND finds nothing —
153
+ // multi-word queries previously required every term to match.
154
+ const results = stmt.all(ftsAndQuery(terms), limit);
155
+ if (results.length > 0 || terms.length === 1)
156
+ return results;
157
+ return stmt.all(ftsOrQuery(terms), limit);
153
158
  }
154
159
  catch {
155
160
  // If FTS5 query fails, fall back to simple LIKE search
@@ -163,7 +168,7 @@ export class GnosysSearch {
163
168
  WHERE content LIKE ? OR title LIKE ? OR tags LIKE ?
164
169
  LIMIT ?
165
170
  `);
166
- const pattern = `%${safeQuery}%`;
171
+ const pattern = `%${terms.join(" ")}%`;
167
172
  return likeStmt.all(pattern, pattern, pattern, limit);
168
173
  }
169
174
  }
@@ -175,8 +180,8 @@ export class GnosysSearch {
175
180
  discover(query, limit = 20) {
176
181
  if (!this.db)
177
182
  return [];
178
- const safeQuery = query.replace(/['"]/g, "").trim();
179
- if (!safeQuery)
183
+ const terms = ftsTerms(query);
184
+ if (terms.length === 0)
180
185
  return [];
181
186
  // Search primarily on relevance + title + tags (not content body)
182
187
  // FTS5 column filter: {relevance title tags}
@@ -191,24 +196,32 @@ export class GnosysSearch {
191
196
  ORDER BY rank
192
197
  LIMIT ?
193
198
  `);
194
- try {
195
- // Try column-filtered search on relevance/title/tags first
196
- const colQuery = `{relevance title tags} : ${safeQuery}`;
197
- const results = stmt.all(colQuery, limit);
198
- if (results.length > 0)
199
- return results;
200
- // Fall back to full-text search if column filter finds nothing
201
- return stmt.all(safeQuery, limit);
202
- }
203
- catch {
204
- // If FTS5 column filter syntax fails, fall back to full search
199
+ const tryRun = (match) => {
205
200
  try {
206
- return stmt.all(safeQuery, limit);
201
+ return stmt.all(match, limit);
207
202
  }
208
203
  catch {
209
204
  return [];
210
205
  }
211
- }
206
+ };
207
+ // v5.12.3: precision-to-recall ladder. AND on the metadata columns,
208
+ // AND anywhere, then OR retries — multi-word queries previously
209
+ // required every term to match, so long queries returned nothing.
210
+ // Parens scope the column filter to the whole expression (a bare
211
+ // `{cols} : a b` only filtered the first term).
212
+ const colScoped = (expr) => `{relevance title tags} : (${expr})`;
213
+ const andExpr = ftsAndQuery(terms);
214
+ let results = tryRun(colScoped(andExpr));
215
+ if (results.length > 0)
216
+ return results;
217
+ results = tryRun(andExpr);
218
+ if (results.length > 0 || terms.length === 1)
219
+ return results;
220
+ const orExpr = ftsOrQuery(terms);
221
+ results = tryRun(colScoped(orExpr));
222
+ if (results.length > 0)
223
+ return results;
224
+ return tryRun(orExpr);
212
225
  }
213
226
  close() {
214
227
  this.db?.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnosys",
3
- "version": "5.12.2",
3
+ "version": "5.12.3",
4
4
  "description": "Gnosys — Persistent Memory for AI Agents. Sandbox-first runtime, central SQLite brain, federated search, Dream Mode, Web Knowledge Base, Obsidian export.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",