gnosys 5.12.3 → 5.13.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.
package/dist/index.js CHANGED
@@ -1853,11 +1853,18 @@ regTool("gnosys_reindex", "Rebuild all semantic embeddings from every memory fil
1853
1853
  // Also rebuild FTS5 index
1854
1854
  await reindexAllStores();
1855
1855
  const count = await hybridSearch.reindex();
1856
+ // v5.13.0: also (re)build the central-DB embedding column — the
1857
+ // vectors DB-mode hybrid/semantic search actually reads.
1858
+ const dbResult = await hybridSearch.reindexCentralDb();
1859
+ const parts = [`${count} file-store memories embedded`];
1860
+ if (dbResult.total > 0) {
1861
+ parts.push(`${dbResult.embedded}/${dbResult.total} central-DB memories embedded`);
1862
+ }
1856
1863
  return {
1857
1864
  content: [
1858
1865
  {
1859
1866
  type: "text",
1860
- text: `Reindex complete: ${count} memories embedded. Hybrid search is now available.`,
1867
+ text: `Reindex complete: ${parts.join(", ")}. Hybrid search is now available.`,
1861
1868
  },
1862
1869
  ],
1863
1870
  };
@@ -3185,6 +3192,14 @@ async function initHeavyDeps() {
3185
3192
  const embeddings = new GnosysEmbeddings(writeTarget.store.getStorePath());
3186
3193
  hybridSearch = new GnosysHybridSearch(search, embeddings, resolver, writeTarget.store.getStorePath(), gnosysDb || undefined);
3187
3194
  askEngine = new GnosysAsk(hybridSearch, config, resolver, writeTarget.store.getStorePath());
3195
+ // v5.13.0: write-time embeddings (serve mode only) — new and updated
3196
+ // memories get vectors without waiting for a manual reindex. Best-effort:
3197
+ // queued writes drain on an unref'd timer and never block a tool call.
3198
+ if (centralDb?.isAvailable() && centralDb.isMigrated()) {
3199
+ const { enableWriteTimeEmbedding } = await import("./lib/embedQueue.js");
3200
+ enableWriteTimeEmbedding(() => centralDb, embeddings);
3201
+ console.error("Write-time embeddings: enabled (central DB)");
3202
+ }
3188
3203
  const embCount = embeddings.hasEmbeddings() ? embeddings.count() : 0;
3189
3204
  console.error(`Hybrid search: ${embCount > 0 ? `ready (${embCount} embeddings)` : "available (run gnosys_reindex to build embeddings)"}`);
3190
3205
  console.error(`Ask engine: ${askEngine.isLLMAvailable ? `ready (${askEngine.providerName}/${askEngine.modelName})` : "disabled (configure LLM provider)"}`);
package/dist/lib/db.d.ts CHANGED
@@ -317,6 +317,21 @@ export declare class GnosysDB {
317
317
  operation?: string;
318
318
  limit?: number;
319
319
  }): DbAuditEntry[];
320
+ /**
321
+ * v5.13.0: rows needed to (re)build the embedding column. All tiers and
322
+ * statuses are included — matching FTS, which indexes every row and
323
+ * filters at query time. Newest-first so capped backfills prioritize
324
+ * recent memories.
325
+ */
326
+ getMemoriesForEmbedding(mode: "missing" | "all", limit?: number): Array<{
327
+ id: string;
328
+ title: string;
329
+ relevance: string | null;
330
+ tags: string;
331
+ content: string;
332
+ }>;
333
+ /** v5.13.0: memories whose embedding column is NULL (embedding-health check). */
334
+ countMemoriesMissingEmbedding(): number;
320
335
  updateEmbedding(id: string, embedding: Buffer): void;
321
336
  getEmbedding(id: string): Buffer | null;
322
337
  getAllEmbeddings(): Array<{
package/dist/lib/db.js CHANGED
@@ -1253,9 +1253,51 @@ export class GnosysDB {
1253
1253
  });
1254
1254
  }
1255
1255
  // ─── Embeddings ─────────────────────────────────────────────────────
1256
+ /**
1257
+ * v5.13.0: rows needed to (re)build the embedding column. All tiers and
1258
+ * statuses are included — matching FTS, which indexes every row and
1259
+ * filters at query time. Newest-first so capped backfills prioritize
1260
+ * recent memories.
1261
+ */
1262
+ getMemoriesForEmbedding(mode, limit) {
1263
+ const where = mode === "missing" ? "WHERE embedding IS NULL" : "";
1264
+ const lim = limit && limit > 0 ? ` LIMIT ${Math.floor(limit)}` : "";
1265
+ return this.withRecovery(() => this.prep(`SELECT id, title, relevance, tags, content FROM memories ${where} ORDER BY modified DESC${lim}`).all());
1266
+ }
1267
+ /** v5.13.0: memories whose embedding column is NULL (embedding-health check). */
1268
+ countMemoriesMissingEmbedding() {
1269
+ return this.withRecovery(() => {
1270
+ const row = this.prep("SELECT COUNT(*) as cnt FROM memories WHERE embedding IS NULL").get();
1271
+ return row.cnt;
1272
+ });
1273
+ }
1256
1274
  updateEmbedding(id, embedding) {
1257
1275
  this.withRecovery(() => {
1258
- this.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(embedding, id);
1276
+ const sql = "UPDATE memories SET embedding = ? WHERE id = ?";
1277
+ try {
1278
+ this.db.prepare(sql).run(embedding, id);
1279
+ }
1280
+ catch {
1281
+ // v5.13.0: same failure mode updateMemory works around — the FTS5
1282
+ // AFTER UPDATE trigger's 'delete' command errors on standalone FTS5
1283
+ // tables. embedding isn't an FTS column, so drop the trigger,
1284
+ // update, recreate; the FTS entry itself needs no rebuild.
1285
+ this.db.exec("DROP TRIGGER IF EXISTS memories_fts_au");
1286
+ this.db.prepare(sql).run(embedding, id);
1287
+ try {
1288
+ this.db.exec(`
1289
+ CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN
1290
+ INSERT INTO memories_fts(memories_fts, id, title, category, tags, relevance, content, summary)
1291
+ VALUES ('delete', old.id, old.title, old.category, old.tags, old.relevance, old.content, old.summary);
1292
+ INSERT INTO memories_fts(id, title, category, tags, relevance, content, summary)
1293
+ VALUES (new.id, new.title, new.category, new.tags, new.relevance, new.content, new.summary);
1294
+ END;
1295
+ `);
1296
+ }
1297
+ catch {
1298
+ // Trigger recreation failed — not critical
1299
+ }
1300
+ }
1259
1301
  });
1260
1302
  }
1261
1303
  getEmbedding(id) {
@@ -14,6 +14,7 @@
14
14
  * become optional — controlled by config.
15
15
  */
16
16
  import { fnv1a } from "./db.js";
17
+ import { queueMemoryEmbedding } from "./embedQueue.js";
17
18
  /** Coerce Date objects (from gray-matter parsing) to ISO date strings. */
18
19
  function toDateStr(value) {
19
20
  if (value instanceof Date)
@@ -61,6 +62,9 @@ export function syncMemoryToDb(db, frontmatter, content, sourcePath, projectId,
61
62
  project_id: projectId || null,
62
63
  scope: scope || "project",
63
64
  });
65
+ // v5.13.0: best-effort write-time embedding (no-op unless the MCP server
66
+ // enabled the queue). Never blocks or fails the write.
67
+ queueMemoryEmbedding(frontmatter.id);
64
68
  }
65
69
  /**
66
70
  * Sync a memory update to gnosys.db after it's been updated in .md.
@@ -113,6 +117,14 @@ export function syncUpdateToDb(db, id, updates, newContent) {
113
117
  }
114
118
  dbUpdates.modified = new Date().toISOString().split("T")[0];
115
119
  db.updateMemory(id, dbUpdates);
120
+ // v5.13.0: re-embed when searchable text changed (title/content/tags/
121
+ // relevance feed the embedding recipe). No-op unless the queue is enabled.
122
+ if (newContent !== undefined ||
123
+ updates.title !== undefined ||
124
+ updates.tags !== undefined ||
125
+ updates.relevance !== undefined) {
126
+ queueMemoryEmbedding(id);
127
+ }
116
128
  }
117
129
  /**
118
130
  * Sync an archive operation to gnosys.db.
@@ -79,6 +79,12 @@ export interface DreamReport {
79
79
  llmCalls?: DreamLLMCallRecord[];
80
80
  totals?: DreamRunRecord["totals"];
81
81
  effectiveness?: DreamEffectivenessRecord;
82
+ /** v5.13.0: embedding coverage found/repaired by the embedding-health phase. */
83
+ embeddingHealth?: {
84
+ total: number;
85
+ missingBefore: number;
86
+ embedded: number;
87
+ };
82
88
  }
83
89
  export interface ReviewSuggestion {
84
90
  memoryId: string;
@@ -99,9 +105,12 @@ export declare class GnosysDreamEngine {
99
105
  private dreamState;
100
106
  private pendingFingerprints;
101
107
  private llmCallsMade;
108
+ /** v5.13.0: explicit dream-state dir (defense against test pollution of ~/.gnosys). */
109
+ private stateDir?;
102
110
  constructor(db: GnosysDB, config: GnosysConfig, dreamConfig?: Partial<DreamConfig>, options?: {
103
111
  trigger?: DreamTrigger;
104
112
  machineId?: string;
113
+ stateDir?: string;
105
114
  });
106
115
  /** Captured at construction if getLLMProvider throws. Used in dream() to write a Layer 2 audit entry. */
107
116
  private providerInitError;
package/dist/lib/dream.js CHANGED
@@ -23,6 +23,7 @@ import { createProvider } from "./llm.js";
23
23
  import { notifyDesktop } from "./desktopNotify.js";
24
24
  import { syncConfidenceToDb, auditToDb } from "./dbWrite.js";
25
25
  import { logError } from "./log.js";
26
+ import { getGnosysHome } from "./paths.js";
26
27
  import { estimateCost, acquireDreamLock, estimateTokens, fingerprintMemories, memoryWatermark, readDreamState, writeDreamState, } from "./dreamRunLog.js";
27
28
  /** Layer 4 alert threshold: fire desktop notification at this many consecutive provider failures. */
28
29
  const DREAM_FAILURE_NOTIFY_THRESHOLD = 3;
@@ -44,6 +45,9 @@ export const DEFAULT_DREAM_CONFIG = {
44
45
  };
45
46
  // ─── Decay Constants ─────────────────────────────────────────────────────
46
47
  const DECAY_LAMBDA = 0.005;
48
+ // v5.13.0: embedding-health backfill cap per dream run. Bounds idle-time
49
+ // work (~128-row batches, local model) — large gaps close over a few runs.
50
+ const EMBEDDING_BACKFILL_MAX_PER_RUN = 512;
47
51
  const STALE_THRESHOLD = 0.3;
48
52
  // ─── Dream Engine ────────────────────────────────────────────────────────
49
53
  export class GnosysDreamEngine {
@@ -55,9 +59,11 @@ export class GnosysDreamEngine {
55
59
  startTime = 0;
56
60
  trigger;
57
61
  machineId;
58
- dreamState = readDreamState();
62
+ dreamState;
59
63
  pendingFingerprints = {};
60
64
  llmCallsMade = 0;
65
+ /** v5.13.0: explicit dream-state dir (defense against test pollution of ~/.gnosys). */
66
+ stateDir;
61
67
  constructor(db, config, dreamConfig, options) {
62
68
  this.db = db;
63
69
  this.config = config;
@@ -68,6 +74,8 @@ export class GnosysDreamEngine {
68
74
  };
69
75
  this.trigger = options?.trigger ?? "manual";
70
76
  this.machineId = options?.machineId;
77
+ this.stateDir = options?.stateDir;
78
+ this.dreamState = readDreamState(this.stateDir);
71
79
  // Initialize LLM provider for dream operations.
72
80
  // v5.4.2: Failure here is no longer silent — when dream tries to actually
73
81
  // run (in dream()), we record the unavailability to audit_log so the
@@ -128,7 +136,7 @@ export class GnosysDreamEngine {
128
136
  ...this.dreamState.analyzedFingerprints,
129
137
  ...this.pendingFingerprints,
130
138
  },
131
- });
139
+ }, this.stateDir);
132
140
  }
133
141
  catch {
134
142
  // Best-effort: a failed checkpoint only costs re-analysis on resume.
@@ -261,7 +269,7 @@ export class GnosysDreamEngine {
261
269
  this.llmCallsMade = 0;
262
270
  this.llmCalls = [];
263
271
  this.pendingFingerprints = {};
264
- this.dreamState = readDreamState();
272
+ this.dreamState = readDreamState(this.stateDir);
265
273
  const log = onProgress || (() => { });
266
274
  const startedAt = new Date().toISOString();
267
275
  const report = {
@@ -368,6 +376,92 @@ export class GnosysDreamEngine {
368
376
  report.abortReason = check.reason;
369
377
  return this.finalize(report);
370
378
  }
379
+ // ─── Phase 1.5: Embedding Health (v5.13.0) ───────────────────────────
380
+ // Semantic search reads memories.embedding; writes only best-effort
381
+ // embed. Dream is the safety net: report coverage and backfill missing
382
+ // vectors during idle time. No LLM involved — local embedding model.
383
+ {
384
+ log("embedding-health", "Phase 1.5: Embedding health check...");
385
+ const embedPhase = this.createPhase("embedding-health");
386
+ const embedStart = Date.now();
387
+ report.phases.push(embedPhase);
388
+ try {
389
+ const total = this.db.getMemoryCount().total;
390
+ const missingBefore = this.db.countMemoriesMissingEmbedding();
391
+ const embeddedCount = this.db.getEmbeddingCount();
392
+ if (missingBefore === 0) {
393
+ embedPhase.status = "skipped";
394
+ embedPhase.reason = "all memories embedded";
395
+ report.embeddingHealth = { total, missingBefore: 0, embedded: 0 };
396
+ log("embedding-health", `All ${total} memories embedded`);
397
+ }
398
+ else if (embeddedCount === 0) {
399
+ // Embeddings never initialized on this install — that's an explicit
400
+ // user choice (reindex downloads the 80 MB model); Dream repairs
401
+ // drift, it doesn't opt users in. Report it loudly and move on.
402
+ embedPhase.status = "skipped";
403
+ embedPhase.reason = "embeddings not initialized — run gnosys_reindex";
404
+ report.embeddingHealth = { total, missingBefore, embedded: 0 };
405
+ auditToDb(this.db, "dream_embedding_health", undefined, {
406
+ missing: missingBefore,
407
+ total,
408
+ initialized: false,
409
+ });
410
+ log("embedding-health", `${missingBefore}/${total} memories lack embeddings — run gnosys_reindex to enable semantic search`);
411
+ }
412
+ else {
413
+ auditToDb(this.db, "dream_embedding_health", undefined, {
414
+ missing: missingBefore,
415
+ total,
416
+ initialized: true,
417
+ });
418
+ const { backfillCentralDbEmbeddings } = await import("./embedDb.js");
419
+ const { GnosysEmbeddings } = await import("./embeddings.js");
420
+ // Model-only use — we never touch the embedder's store-local db.
421
+ const embedder = new GnosysEmbeddings(getGnosysHome());
422
+ let embedded = 0;
423
+ try {
424
+ // Chunked so shouldStop() (abort / max-runtime) is honored
425
+ // between batches; capped per run to bound idle-time work.
426
+ while (embedded < EMBEDDING_BACKFILL_MAX_PER_RUN) {
427
+ if (this.shouldStop().stop)
428
+ break;
429
+ const chunk = Math.min(128, EMBEDDING_BACKFILL_MAX_PER_RUN - embedded);
430
+ const result = await backfillCentralDbEmbeddings(this.db, embedder, {
431
+ mode: "missing",
432
+ limit: chunk,
433
+ });
434
+ embedded += result.embedded;
435
+ if (result.embedded === 0)
436
+ break;
437
+ }
438
+ }
439
+ finally {
440
+ embedder.close();
441
+ }
442
+ report.embeddingHealth = { total, missingBefore, embedded };
443
+ if (embedded > 0) {
444
+ auditToDb(this.db, "dream_embedding_backfill", undefined, {
445
+ embedded,
446
+ remaining: missingBefore - embedded,
447
+ });
448
+ }
449
+ log("embedding-health", `Embedded ${embedded}/${missingBefore} missing (${total} total)`);
450
+ }
451
+ }
452
+ catch (err) {
453
+ report.errors.push(`Embedding health: ${err instanceof Error ? err.message : String(err)}`);
454
+ }
455
+ finally {
456
+ this.finishPhase(embedPhase, embedStart);
457
+ }
458
+ check = this.shouldStop();
459
+ if (check.stop) {
460
+ report.aborted = true;
461
+ report.abortReason = check.reason;
462
+ return this.finalize(report);
463
+ }
464
+ }
371
465
  // ─── Phase 2: Self-Critique (Review Suggestions) ─────────────────────
372
466
  if (this.dreamConfig.selfCritique) {
373
467
  log("critique", "Phase 2: Self-critique...");
@@ -487,7 +581,7 @@ export class GnosysDreamEngine {
487
581
  ...this.dreamState.analyzedFingerprints,
488
582
  ...this.pendingFingerprints,
489
583
  },
490
- });
584
+ }, this.stateDir);
491
585
  // v5.4.2: A run is considered "successful with LLM work" if any of the
492
586
  // LLM-dependent counters moved. Resetting the consecutive-failure count
493
587
  // here ensures Layer 4 doesn't keep firing once dream is healthy again.
@@ -9,7 +9,7 @@ export interface DreamRunGateResult {
9
9
  details?: Record<string, unknown>;
10
10
  }
11
11
  export interface DreamRunPhaseRecord {
12
- name: "decay" | "critique" | "summaries" | "relationships";
12
+ name: "decay" | "embedding-health" | "critique" | "summaries" | "relationships";
13
13
  status: "ran" | "skipped";
14
14
  reason?: string;
15
15
  durationMs: number;
@@ -86,7 +86,7 @@ export interface DreamReadOptions {
86
86
  status?: DreamRunStatus;
87
87
  }
88
88
  export declare function getDreamRunsPath(): string;
89
- export declare function getDreamStatePath(): string;
89
+ export declare function getDreamStatePath(baseDir?: string): string;
90
90
  export declare function getDreamLockPath(): string;
91
91
  export declare function acquireDreamLock(depth?: number): {
92
92
  acquired: true;
@@ -97,8 +97,8 @@ export declare function acquireDreamLock(depth?: number): {
97
97
  };
98
98
  export declare function estimateTokens(text: string): number;
99
99
  export declare function estimateCost(model: string | undefined, inputTokens: number, outputTokens: number): number;
100
- export declare function readDreamState(): DreamState;
101
- export declare function writeDreamState(state: DreamState): void;
100
+ export declare function readDreamState(baseDir?: string): DreamState;
101
+ export declare function writeDreamState(state: DreamState, baseDir?: string): void;
102
102
  export declare function appendDreamRun(record: DreamRunRecord): void;
103
103
  export declare function readDreamRuns(opts?: DreamReadOptions): DreamRunRecord[];
104
104
  export declare function fingerprintMemories(kind: "summary" | "critique" | "relationship", memories: DbMemory[]): string;
@@ -20,8 +20,11 @@ const MODEL_PRICES_USD_PER_MILLION = {
20
20
  export function getDreamRunsPath() {
21
21
  return path.join(getGnosysHome(), "dream-runs.jsonl");
22
22
  }
23
- export function getDreamStatePath() {
24
- return path.join(getGnosysHome(), "dream-state.json");
23
+ // v5.13.0: baseDir injection (defense in depth against test pollution)
24
+ // in-process engines can target an explicit state dir instead of the
25
+ // developer's real ~/.gnosys.
26
+ export function getDreamStatePath(baseDir) {
27
+ return path.join(baseDir || getGnosysHome(), "dream-state.json");
25
28
  }
26
29
  export function getDreamLockPath() {
27
30
  return path.join(getGnosysHome(), "dream.lock");
@@ -103,9 +106,9 @@ export function estimateCost(model, inputTokens, outputTokens) {
103
106
  const cost = (inputTokens / 1_000_000) * fuzzy.input + (outputTokens / 1_000_000) * fuzzy.output;
104
107
  return Math.round(cost * 1_000_000) / 1_000_000;
105
108
  }
106
- export function readDreamState() {
109
+ export function readDreamState(baseDir) {
107
110
  try {
108
- const raw = fs.readFileSync(getDreamStatePath(), "utf8");
111
+ const raw = fs.readFileSync(getDreamStatePath(baseDir), "utf8");
109
112
  const parsed = JSON.parse(raw);
110
113
  return {
111
114
  ...DEFAULT_STATE,
@@ -117,8 +120,8 @@ export function readDreamState() {
117
120
  return { ...DEFAULT_STATE };
118
121
  }
119
122
  }
120
- export function writeDreamState(state) {
121
- const file = getDreamStatePath();
123
+ export function writeDreamState(state, baseDir) {
124
+ const file = getDreamStatePath(baseDir);
122
125
  fs.mkdirSync(path.dirname(file), { recursive: true });
123
126
  fs.writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
124
127
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Central-DB embedding utilities (v5.13.0).
3
+ *
4
+ * GnosysDbSearch reads vectors from the memories.embedding column of
5
+ * gnosys.db, but before v5.13.0 nothing ever wrote that column: the
6
+ * reindex flow embedded file-store memories into the store-local
7
+ * embeddings.db, so DB-mode hybrid search could never run its semantic
8
+ * leg. This module owns the column — full backfill for reindex,
9
+ * missing-only backfill for Dream/maintenance, and single-memory
10
+ * embedding for the write-time queue.
11
+ *
12
+ * Embeddings are derived, rebuildable data: regenerating them is always
13
+ * safe, and a sync snapshot refresh replacing them is not a loss.
14
+ */
15
+ import type { GnosysDB } from "./db.js";
16
+ import type { GnosysEmbeddings } from "./embeddings.js";
17
+ /** Row fields needed to build embedding text. */
18
+ export interface EmbeddableMemoryRow {
19
+ id: string;
20
+ title: string;
21
+ relevance: string | null;
22
+ tags: string;
23
+ content: string;
24
+ }
25
+ /**
26
+ * The text recipe — identical to the file-store reindex in hybridSearch.ts
27
+ * (title, relevance keywords, tags, content) so query-vs-document
28
+ * similarity behaves the same in both modes.
29
+ */
30
+ export declare function embeddingText(m: EmbeddableMemoryRow): string;
31
+ /** View a Float32Array as the Buffer shape memories.embedding stores. */
32
+ export declare function float32ToBuffer(vec: Float32Array): Buffer;
33
+ /**
34
+ * (Re)build the memories.embedding column.
35
+ *
36
+ * mode "all" regenerates every memory (reindex semantics); "missing" only
37
+ * fills NULL rows (write-gap backfill for Dream/maintenance, newest first).
38
+ * Batched through embedBatch for model efficiency.
39
+ */
40
+ export declare function backfillCentralDbEmbeddings(db: GnosysDB, embeddings: GnosysEmbeddings, opts?: {
41
+ mode?: "missing" | "all";
42
+ batchSize?: number;
43
+ limit?: number;
44
+ onProgress?: (current: number, total: number, id: string) => void;
45
+ }): Promise<{
46
+ embedded: number;
47
+ total: number;
48
+ }>;
49
+ /**
50
+ * Embed one memory and store its vector. Returns false if the memory
51
+ * doesn't exist; throws on embedder failure — callers decide how loud.
52
+ */
53
+ export declare function embedMemoryIntoDb(db: GnosysDB, embeddings: GnosysEmbeddings, id: string): Promise<boolean>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Central-DB embedding utilities (v5.13.0).
3
+ *
4
+ * GnosysDbSearch reads vectors from the memories.embedding column of
5
+ * gnosys.db, but before v5.13.0 nothing ever wrote that column: the
6
+ * reindex flow embedded file-store memories into the store-local
7
+ * embeddings.db, so DB-mode hybrid search could never run its semantic
8
+ * leg. This module owns the column — full backfill for reindex,
9
+ * missing-only backfill for Dream/maintenance, and single-memory
10
+ * embedding for the write-time queue.
11
+ *
12
+ * Embeddings are derived, rebuildable data: regenerating them is always
13
+ * safe, and a sync snapshot refresh replacing them is not a loss.
14
+ */
15
+ /**
16
+ * The text recipe — identical to the file-store reindex in hybridSearch.ts
17
+ * (title, relevance keywords, tags, content) so query-vs-document
18
+ * similarity behaves the same in both modes.
19
+ */
20
+ export function embeddingText(m) {
21
+ let tags = m.tags || "";
22
+ try {
23
+ const parsed = JSON.parse(tags);
24
+ if (Array.isArray(parsed)) {
25
+ tags = parsed.join(" ");
26
+ }
27
+ else if (parsed && typeof parsed === "object") {
28
+ tags = Object.values(parsed).flat().join(" ");
29
+ }
30
+ }
31
+ catch {
32
+ // already a plain string
33
+ }
34
+ return `${m.title}\n${m.relevance || ""}\n${tags}\n${m.content}`;
35
+ }
36
+ /** View a Float32Array as the Buffer shape memories.embedding stores. */
37
+ export function float32ToBuffer(vec) {
38
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
39
+ }
40
+ /**
41
+ * (Re)build the memories.embedding column.
42
+ *
43
+ * mode "all" regenerates every memory (reindex semantics); "missing" only
44
+ * fills NULL rows (write-gap backfill for Dream/maintenance, newest first).
45
+ * Batched through embedBatch for model efficiency.
46
+ */
47
+ export async function backfillCentralDbEmbeddings(db, embeddings, opts = {}) {
48
+ if (!db.isAvailable() || !db.isMigrated())
49
+ return { embedded: 0, total: 0 };
50
+ const rows = db.getMemoriesForEmbedding(opts.mode || "missing", opts.limit);
51
+ const total = rows.length;
52
+ if (total === 0)
53
+ return { embedded: 0, total: 0 };
54
+ const batchSize = opts.batchSize || 32;
55
+ let embedded = 0;
56
+ for (let i = 0; i < rows.length; i += batchSize) {
57
+ const batch = rows.slice(i, i + batchSize);
58
+ const vectors = await embeddings.embedBatch(batch.map(embeddingText));
59
+ for (let j = 0; j < batch.length; j++) {
60
+ db.updateEmbedding(batch[j].id, float32ToBuffer(vectors[j]));
61
+ embedded++;
62
+ opts.onProgress?.(embedded, total, batch[j].id);
63
+ }
64
+ }
65
+ return { embedded, total };
66
+ }
67
+ /**
68
+ * Embed one memory and store its vector. Returns false if the memory
69
+ * doesn't exist; throws on embedder failure — callers decide how loud.
70
+ */
71
+ export async function embedMemoryIntoDb(db, embeddings, id) {
72
+ const mem = db.getMemory(id);
73
+ if (!mem)
74
+ return false;
75
+ const vec = await embeddings.embed(embeddingText({
76
+ id: mem.id,
77
+ title: mem.title,
78
+ relevance: mem.relevance,
79
+ tags: mem.tags,
80
+ content: mem.content,
81
+ }));
82
+ db.updateEmbedding(id, float32ToBuffer(vec));
83
+ return true;
84
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Write-time embedding queue (v5.13.0).
3
+ *
4
+ * Keeps memories.embedding fresh as memories are written, so semantic
5
+ * search doesn't drift stale between reindexes. Disabled by default —
6
+ * the MCP server enables it once its long-lived central-DB handle and
7
+ * embeddings instance exist. CLI one-shot processes stay disabled (they
8
+ * exit before the model could load) and rely on reindex / Dream backfill.
9
+ *
10
+ * Contract: a memory write must NEVER block on or fail because of
11
+ * embedding. queueMemoryEmbedding returns immediately; the drain runs on
12
+ * an unref'd timer, lazily imports the embedder path, logs at most one
13
+ * stderr warning per process on failure, and drops rather than retries.
14
+ */
15
+ import type { GnosysDB } from "./db.js";
16
+ import type { GnosysEmbeddings } from "./embeddings.js";
17
+ /** Turn on write-time embedding (serve mode). Getter is called at drain time. */
18
+ export declare function enableWriteTimeEmbedding(dbGetter: () => GnosysDB | null, embeddings: GnosysEmbeddings): void;
19
+ /** Turn off and clear the queue (tests, shutdown). */
20
+ export declare function disableWriteTimeEmbedding(): void;
21
+ export declare function isWriteTimeEmbeddingEnabled(): boolean;
22
+ /**
23
+ * Queue a memory for embedding. No-op unless enabled. Never throws,
24
+ * never blocks — safe to call from any write path.
25
+ */
26
+ export declare function queueMemoryEmbedding(id: string): void;
27
+ /** Await in-flight embedding work. Used by tests and graceful shutdown. */
28
+ export declare function flushWriteTimeEmbeddings(): Promise<void>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Write-time embedding queue (v5.13.0).
3
+ *
4
+ * Keeps memories.embedding fresh as memories are written, so semantic
5
+ * search doesn't drift stale between reindexes. Disabled by default —
6
+ * the MCP server enables it once its long-lived central-DB handle and
7
+ * embeddings instance exist. CLI one-shot processes stay disabled (they
8
+ * exit before the model could load) and rely on reindex / Dream backfill.
9
+ *
10
+ * Contract: a memory write must NEVER block on or fail because of
11
+ * embedding. queueMemoryEmbedding returns immediately; the drain runs on
12
+ * an unref'd timer, lazily imports the embedder path, logs at most one
13
+ * stderr warning per process on failure, and drops rather than retries.
14
+ */
15
+ let getDb = null;
16
+ let embedder = null;
17
+ const pending = new Set();
18
+ let drainPromise = null;
19
+ let warnedOnce = false;
20
+ /** Turn on write-time embedding (serve mode). Getter is called at drain time. */
21
+ export function enableWriteTimeEmbedding(dbGetter, embeddings) {
22
+ getDb = dbGetter;
23
+ embedder = embeddings;
24
+ }
25
+ /** Turn off and clear the queue (tests, shutdown). */
26
+ export function disableWriteTimeEmbedding() {
27
+ getDb = null;
28
+ embedder = null;
29
+ pending.clear();
30
+ }
31
+ export function isWriteTimeEmbeddingEnabled() {
32
+ return getDb !== null && embedder !== null;
33
+ }
34
+ /**
35
+ * Queue a memory for embedding. No-op unless enabled. Never throws,
36
+ * never blocks — safe to call from any write path.
37
+ */
38
+ export function queueMemoryEmbedding(id) {
39
+ if (!getDb || !embedder || !id)
40
+ return;
41
+ pending.add(id);
42
+ if (!drainPromise) {
43
+ drainPromise = new Promise((resolve) => {
44
+ const timer = setTimeout(() => {
45
+ drain().finally(() => {
46
+ drainPromise = null;
47
+ resolve();
48
+ });
49
+ }, 25);
50
+ timer.unref?.();
51
+ });
52
+ }
53
+ }
54
+ /** Await in-flight embedding work. Used by tests and graceful shutdown. */
55
+ export async function flushWriteTimeEmbeddings() {
56
+ while (drainPromise) {
57
+ await drainPromise;
58
+ }
59
+ }
60
+ async function drain() {
61
+ while (pending.size > 0) {
62
+ const id = pending.values().next().value;
63
+ pending.delete(id);
64
+ const db = getDb?.();
65
+ if (!db || !embedder)
66
+ return;
67
+ try {
68
+ const { embedMemoryIntoDb } = await import("./embedDb.js");
69
+ await embedMemoryIntoDb(db, embedder, id);
70
+ }
71
+ catch (err) {
72
+ if (!warnedOnce) {
73
+ warnedOnce = true;
74
+ console.error(`Gnosys: write-time embedding failed (${err instanceof Error ? err.message : err}). ` +
75
+ `New memories will be embedded by the next gnosys_reindex or Dream run instead.`);
76
+ }
77
+ return; // drop this round; anything still pending waits for the next queue call
78
+ }
79
+ }
80
+ }
@@ -20,6 +20,8 @@ export declare class GnosysHybridSearch {
20
20
  private storePath;
21
21
  /** v2.0: When set, hybrid search uses SQLite directly */
22
22
  private dbSearch;
23
+ /** v5.13.0: kept for central-DB embedding backfill (reindexCentralDb) */
24
+ private gnosysDb;
23
25
  constructor(search: GnosysSearch, embeddings: GnosysEmbeddings, resolver: GnosysResolver, storePath: string, gnosysDb?: GnosysDB);
24
26
  /**
25
27
  * Main hybrid search entry point.
@@ -48,6 +50,17 @@ export declare class GnosysHybridSearch {
48
50
  * Returns count of files indexed.
49
51
  */
50
52
  reindex(onProgress?: (current: number, total: number, filePath: string) => void): Promise<number>;
53
+ /**
54
+ * v5.13.0: (re)build the central-DB memories.embedding column — the
55
+ * vectors GnosysDbSearch actually reads. Before this, reindex only
56
+ * embedded file-store memories into the store-local embeddings.db, so
57
+ * DB-mode semantic search could never activate. Returns counts; no-op
58
+ * ({0,0}) when no migrated central DB is attached.
59
+ */
60
+ reindexCentralDb(onProgress?: (current: number, total: number, id: string) => void): Promise<{
61
+ embedded: number;
62
+ total: number;
63
+ }>;
51
64
  /**
52
65
  * Load full content for search results (used by Ask engine).
53
66
  * Handles both active memories and archived memories.
@@ -59,11 +72,10 @@ export declare class GnosysHybridSearch {
59
72
  hasEmbeddings(): boolean;
60
73
  /**
61
74
  * 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).
75
+ * Mirrors the embedQuery gate in hybridSearch(): in DB mode, stored
76
+ * central-DB vectors are what matter (the query embedder loads the
77
+ * local model on demand); in legacy file mode, the store-local
78
+ * embeddings.db must be populated.
67
79
  */
68
80
  canRunSemantic(): boolean;
69
81
  /**
@@ -19,6 +19,8 @@ export class GnosysHybridSearch {
19
19
  storePath;
20
20
  /** v2.0: When set, hybrid search uses SQLite directly */
21
21
  dbSearch = null;
22
+ /** v5.13.0: kept for central-DB embedding backfill (reindexCentralDb) */
23
+ gnosysDb = null;
22
24
  constructor(search, embeddings, resolver, storePath, gnosysDb) {
23
25
  this.search = search;
24
26
  this.embeddings = embeddings;
@@ -27,6 +29,7 @@ export class GnosysHybridSearch {
27
29
  // v2.0: If GnosysDB is migrated, create a DB search adapter
28
30
  if (gnosysDb?.isAvailable() && gnosysDb?.isMigrated()) {
29
31
  this.dbSearch = new GnosysDbSearch(gnosysDb);
32
+ this.gnosysDb = gnosysDb;
30
33
  }
31
34
  }
32
35
  /**
@@ -36,7 +39,11 @@ export class GnosysHybridSearch {
36
39
  async hybridSearch(query, limit = 15, mode = "hybrid") {
37
40
  // v2.0 DB-backed fast path: run entirely from gnosys.db
38
41
  if (this.dbSearch) {
39
- const embedQuery = this.embeddings.hasEmbeddings()
42
+ // v5.13.0: gate the semantic leg on stored central-DB vectors — the
43
+ // query embedder loads the local model on demand, so the store-local
44
+ // embeddings.db (the old gate) is irrelevant in DB mode. A machine
45
+ // that only syncs gnosys.db gets semantic search once vectors exist.
46
+ const embedQuery = this.dbSearch.hasEmbeddings()
40
47
  ? (text) => this.embeddings.embed(text)
41
48
  : undefined;
42
49
  return this.dbSearch.hybridSearch(query, limit, mode, embedQuery);
@@ -230,6 +237,22 @@ export class GnosysHybridSearch {
230
237
  }
231
238
  return indexed;
232
239
  }
240
+ /**
241
+ * v5.13.0: (re)build the central-DB memories.embedding column — the
242
+ * vectors GnosysDbSearch actually reads. Before this, reindex only
243
+ * embedded file-store memories into the store-local embeddings.db, so
244
+ * DB-mode semantic search could never activate. Returns counts; no-op
245
+ * ({0,0}) when no migrated central DB is attached.
246
+ */
247
+ async reindexCentralDb(onProgress) {
248
+ if (!this.gnosysDb)
249
+ return { embedded: 0, total: 0 };
250
+ const { backfillCentralDbEmbeddings } = await import("./embedDb.js");
251
+ return backfillCentralDbEmbeddings(this.gnosysDb, this.embeddings, {
252
+ mode: "all",
253
+ onProgress,
254
+ });
255
+ }
233
256
  /**
234
257
  * Load full content for search results (used by Ask engine).
235
258
  * Handles both active memories and archived memories.
@@ -286,15 +309,14 @@ export class GnosysHybridSearch {
286
309
  }
287
310
  /**
288
311
  * 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).
312
+ * Mirrors the embedQuery gate in hybridSearch(): in DB mode, stored
313
+ * central-DB vectors are what matter (the query embedder loads the
314
+ * local model on demand); in legacy file mode, the store-local
315
+ * embeddings.db must be populated.
294
316
  */
295
317
  canRunSemantic() {
296
318
  if (this.dbSearch) {
297
- return this.dbSearch.hasEmbeddings() && this.embeddings.hasEmbeddings();
319
+ return this.dbSearch.hasEmbeddings();
298
320
  }
299
321
  return this.embeddings.hasEmbeddings();
300
322
  }
@@ -24,7 +24,33 @@ export async function runReindexCommand(getResolver) {
24
24
  const count = await hybridSearch.reindex((current, total, filePath) => {
25
25
  process.stdout.write(`\r Indexing: ${current}/${total} — ${filePath.substring(0, 60)}`);
26
26
  });
27
+ // v5.13.0: also (re)build the central-DB embedding column — the vectors
28
+ // DB-mode hybrid/semantic search actually reads. Own handle: the
29
+ // resolver-based hybridSearch above only covers file stores.
30
+ let dbEmbedded = 0;
31
+ let dbTotal = 0;
32
+ const { GnosysDB } = await import("./db.js");
33
+ const centralDb = GnosysDB.openCentral();
34
+ try {
35
+ if (centralDb.isAvailable() && centralDb.isMigrated()) {
36
+ const { backfillCentralDbEmbeddings } = await import("./embedDb.js");
37
+ const result = await backfillCentralDbEmbeddings(centralDb, embeddings, {
38
+ mode: "all",
39
+ onProgress: (current, total, id) => {
40
+ process.stdout.write(`\r Central DB: ${current}/${total} — ${id.substring(0, 40)} `);
41
+ },
42
+ });
43
+ dbEmbedded = result.embedded;
44
+ dbTotal = result.total;
45
+ }
46
+ }
47
+ finally {
48
+ centralDb.close();
49
+ }
27
50
  console.log(`\n\nReindex complete: ${count} memories embedded.`);
51
+ if (dbTotal > 0) {
52
+ console.log(`Central DB: ${dbEmbedded}/${dbTotal} memories embedded.`);
53
+ }
28
54
  console.log("Hybrid and semantic search are now available.");
29
55
  }
30
56
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnosys",
3
- "version": "5.12.3",
3
+ "version": "5.13.0",
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",