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 +20 -4
- package/dist/lib/archive.js +10 -4
- package/dist/lib/db.js +51 -33
- package/dist/lib/ftsQuery.d.ts +25 -0
- package/dist/lib/ftsQuery.js +42 -0
- package/dist/lib/hybridSearch.d.ts +9 -0
- package/dist/lib/hybridSearch.js +14 -0
- package/dist/lib/hybridSearchCommand.js +5 -0
- package/dist/lib/resolver.d.ts +17 -0
- package/dist/lib/resolver.js +43 -17
- package/dist/lib/search.js +33 -20
- package/package.json +1 -1
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
|
|
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:
|
|
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:
|
|
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}".
|
|
1824
|
+
content: [{ type: "text", text: `No semantic results for "${query}". Try a broader query.` }],
|
|
1809
1825
|
};
|
|
1810
1826
|
}
|
|
1811
1827
|
const formatted = results
|
package/dist/lib/archive.js
CHANGED
|
@@ -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
|
|
221
|
-
if (
|
|
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
|
-
|
|
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 = `%${
|
|
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
|
|
1092
|
-
if (
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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 = `%${
|
|
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
|
|
1122
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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
|
*/
|
package/dist/lib/hybridSearch.js
CHANGED
|
@@ -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: [] }, () => {
|
package/dist/lib/resolver.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/lib/resolver.js
CHANGED
|
@@ -79,8 +79,13 @@ export class GnosysResolver {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
catch {
|
|
82
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
142
|
-
//
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
package/dist/lib/search.js
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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 = `%${
|
|
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
|
|
179
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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",
|