@poolzin/pool-bot 2026.2.10 → 2026.2.17

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/agents/auth-profiles/usage.js +22 -0
  3. package/dist/agents/auth-profiles.js +1 -1
  4. package/dist/agents/bash-tools.exec.js +4 -6
  5. package/dist/agents/glob-pattern.js +42 -0
  6. package/dist/agents/memory-search.js +33 -0
  7. package/dist/agents/model-fallback.js +59 -8
  8. package/dist/agents/pi-tools.before-tool-call.js +145 -4
  9. package/dist/agents/pi-tools.js +27 -9
  10. package/dist/agents/pi-tools.policy.js +85 -92
  11. package/dist/agents/pi-tools.schema.js +54 -27
  12. package/dist/agents/sandbox/validate-sandbox-security.js +157 -0
  13. package/dist/agents/sandbox-tool-policy.js +26 -0
  14. package/dist/agents/sanitize-for-prompt.js +18 -0
  15. package/dist/agents/session-write-lock.js +203 -39
  16. package/dist/agents/system-prompt.js +52 -10
  17. package/dist/agents/tool-loop-detection.js +466 -0
  18. package/dist/agents/tool-policy.js +6 -0
  19. package/dist/auto-reply/reply/post-compaction-audit.js +96 -0
  20. package/dist/auto-reply/reply/post-compaction-context.js +98 -0
  21. package/dist/build-info.json +3 -3
  22. package/dist/config/zod-schema.agent-defaults.js +14 -0
  23. package/dist/config/zod-schema.agent-runtime.js +14 -0
  24. package/dist/infra/path-safety.js +16 -0
  25. package/dist/logging/diagnostic-session-state.js +73 -0
  26. package/dist/logging/diagnostic.js +22 -0
  27. package/dist/memory/embeddings.js +36 -9
  28. package/dist/memory/hybrid.js +24 -5
  29. package/dist/memory/manager.js +76 -28
  30. package/dist/memory/mmr.js +164 -0
  31. package/dist/memory/query-expansion.js +331 -0
  32. package/dist/memory/temporal-decay.js +119 -0
  33. package/dist/process/kill-tree.js +98 -0
  34. package/dist/shared/pid-alive.js +12 -0
  35. package/dist/shared/process-scoped-map.js +10 -0
  36. package/extensions/bluebubbles/package.json +1 -1
  37. package/extensions/copilot-proxy/package.json +1 -1
  38. package/extensions/diagnostics-otel/package.json +1 -1
  39. package/extensions/discord/package.json +1 -1
  40. package/extensions/google-antigravity-auth/package.json +1 -1
  41. package/extensions/google-gemini-cli-auth/package.json +1 -1
  42. package/extensions/googlechat/package.json +1 -1
  43. package/extensions/imessage/package.json +1 -1
  44. package/extensions/line/package.json +1 -1
  45. package/extensions/llm-task/package.json +1 -1
  46. package/extensions/lobster/package.json +1 -1
  47. package/extensions/matrix/CHANGELOG.md +5 -0
  48. package/extensions/matrix/package.json +1 -1
  49. package/extensions/mattermost/package.json +1 -1
  50. package/extensions/memory-core/package.json +1 -1
  51. package/extensions/memory-lancedb/package.json +1 -1
  52. package/extensions/msteams/CHANGELOG.md +5 -0
  53. package/extensions/msteams/package.json +1 -1
  54. package/extensions/nextcloud-talk/package.json +1 -1
  55. package/extensions/nostr/CHANGELOG.md +5 -0
  56. package/extensions/nostr/package.json +1 -1
  57. package/extensions/open-prose/package.json +1 -1
  58. package/extensions/signal/package.json +1 -1
  59. package/extensions/slack/package.json +1 -1
  60. package/extensions/telegram/package.json +1 -1
  61. package/extensions/tlon/package.json +1 -1
  62. package/extensions/twitch/CHANGELOG.md +5 -0
  63. package/extensions/twitch/package.json +1 -1
  64. package/extensions/voice-call/CHANGELOG.md +5 -0
  65. package/extensions/voice-call/package.json +1 -1
  66. package/extensions/whatsapp/package.json +1 -1
  67. package/extensions/zalo/CHANGELOG.md +5 -0
  68. package/extensions/zalo/package.json +1 -1
  69. package/extensions/zalouser/CHANGELOG.md +5 -0
  70. package/extensions/zalouser/package.json +1 -1
  71. package/package.json +1 -1
@@ -125,6 +125,20 @@ export const AgentDefaultsSchema = z
125
125
  subagents: z
126
126
  .object({
127
127
  maxConcurrent: z.number().int().positive().optional(),
128
+ maxSpawnDepth: z
129
+ .number()
130
+ .int()
131
+ .min(1)
132
+ .max(5)
133
+ .optional()
134
+ .describe("Maximum nesting depth for sub-agent spawning. 1 = no nesting (default), 2 = sub-agents can spawn sub-sub-agents."),
135
+ maxChildrenPerAgent: z
136
+ .number()
137
+ .int()
138
+ .min(1)
139
+ .max(20)
140
+ .optional()
141
+ .describe("Maximum active children a single requester session may spawn. Default: 5."),
128
142
  archiveAfterMinutes: z.number().int().positive().optional(),
129
143
  model: z
130
144
  .union([
@@ -372,6 +372,20 @@ export const MemorySearchSchema = z
372
372
  vectorWeight: z.number().min(0).max(1).optional(),
373
373
  textWeight: z.number().min(0).max(1).optional(),
374
374
  candidateMultiplier: z.number().int().positive().optional(),
375
+ mmr: z
376
+ .object({
377
+ enabled: z.boolean().optional(),
378
+ lambda: z.number().min(0).max(1).optional(),
379
+ })
380
+ .strict()
381
+ .optional(),
382
+ temporalDecay: z
383
+ .object({
384
+ enabled: z.boolean().optional(),
385
+ halfLifeDays: z.number().int().positive().optional(),
386
+ })
387
+ .strict()
388
+ .optional(),
375
389
  })
376
390
  .strict()
377
391
  .optional(),
@@ -0,0 +1,16 @@
1
+ import path from "node:path";
2
+ export function resolveSafeBaseDir(rootDir) {
3
+ const resolved = path.resolve(rootDir);
4
+ return resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
5
+ }
6
+ export function isWithinDir(rootDir, targetPath) {
7
+ const resolvedRoot = path.resolve(rootDir);
8
+ const resolvedTarget = path.resolve(targetPath);
9
+ // Windows paths are effectively case-insensitive; normalize to avoid false negatives.
10
+ if (process.platform === "win32") {
11
+ const relative = path.win32.relative(resolvedRoot.toLowerCase(), resolvedTarget.toLowerCase());
12
+ return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
13
+ }
14
+ const relative = path.relative(resolvedRoot, resolvedTarget);
15
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
16
+ }
@@ -0,0 +1,73 @@
1
+ export const diagnosticSessionStates = new Map();
2
+ const SESSION_STATE_TTL_MS = 30 * 60 * 1000;
3
+ const SESSION_STATE_PRUNE_INTERVAL_MS = 60 * 1000;
4
+ const SESSION_STATE_MAX_ENTRIES = 2000;
5
+ let lastSessionPruneAt = 0;
6
+ export function pruneDiagnosticSessionStates(now = Date.now(), force = false) {
7
+ const shouldPruneForSize = diagnosticSessionStates.size > SESSION_STATE_MAX_ENTRIES;
8
+ if (!force && !shouldPruneForSize && now - lastSessionPruneAt < SESSION_STATE_PRUNE_INTERVAL_MS) {
9
+ return;
10
+ }
11
+ lastSessionPruneAt = now;
12
+ for (const [key, state] of diagnosticSessionStates.entries()) {
13
+ const ageMs = now - state.lastActivity;
14
+ const isIdle = state.state === "idle";
15
+ if (isIdle && state.queueDepth <= 0 && ageMs > SESSION_STATE_TTL_MS) {
16
+ diagnosticSessionStates.delete(key);
17
+ }
18
+ }
19
+ if (diagnosticSessionStates.size <= SESSION_STATE_MAX_ENTRIES) {
20
+ return;
21
+ }
22
+ const excess = diagnosticSessionStates.size - SESSION_STATE_MAX_ENTRIES;
23
+ const ordered = Array.from(diagnosticSessionStates.entries()).toSorted((a, b) => a[1].lastActivity - b[1].lastActivity);
24
+ for (let i = 0; i < excess; i += 1) {
25
+ const key = ordered[i]?.[0];
26
+ if (!key) {
27
+ break;
28
+ }
29
+ diagnosticSessionStates.delete(key);
30
+ }
31
+ }
32
+ function resolveSessionKey({ sessionKey, sessionId }) {
33
+ return sessionKey ?? sessionId ?? "unknown";
34
+ }
35
+ function findStateBySessionId(sessionId) {
36
+ for (const state of diagnosticSessionStates.values()) {
37
+ if (state.sessionId === sessionId) {
38
+ return state;
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ export function getDiagnosticSessionState(ref) {
44
+ pruneDiagnosticSessionStates();
45
+ const key = resolveSessionKey(ref);
46
+ const existing = diagnosticSessionStates.get(key) ?? (ref.sessionId && findStateBySessionId(ref.sessionId));
47
+ if (existing) {
48
+ if (ref.sessionId) {
49
+ existing.sessionId = ref.sessionId;
50
+ }
51
+ if (ref.sessionKey) {
52
+ existing.sessionKey = ref.sessionKey;
53
+ }
54
+ return existing;
55
+ }
56
+ const created = {
57
+ sessionId: ref.sessionId,
58
+ sessionKey: ref.sessionKey,
59
+ lastActivity: Date.now(),
60
+ state: "idle",
61
+ queueDepth: 0,
62
+ };
63
+ diagnosticSessionStates.set(key, created);
64
+ pruneDiagnosticSessionStates(Date.now(), true);
65
+ return created;
66
+ }
67
+ export function getDiagnosticSessionStateCountForTest() {
68
+ return diagnosticSessionStates.size;
69
+ }
70
+ export function resetDiagnosticSessionStateForTest() {
71
+ diagnosticSessionStates.clear();
72
+ lastSessionPruneAt = 0;
73
+ }
@@ -165,6 +165,28 @@ export function logLaneDequeue(lane, waitMs, queueSize) {
165
165
  });
166
166
  markActivity();
167
167
  }
168
+ export function logToolLoopAction(params) {
169
+ const payload = `tool loop: sessionId=${params.sessionId ?? "unknown"} sessionKey=${params.sessionKey ?? "unknown"} tool=${params.toolName} level=${params.level} action=${params.action} detector=${params.detector} count=${params.count}${params.pairedToolName ? ` pairedTool=${params.pairedToolName}` : ""} message="${params.message}"`;
170
+ if (params.level === "critical") {
171
+ diag.error(payload);
172
+ }
173
+ else {
174
+ diag.warn(payload);
175
+ }
176
+ emitDiagnosticEvent({
177
+ type: "tool.loop",
178
+ sessionId: params.sessionId,
179
+ sessionKey: params.sessionKey,
180
+ toolName: params.toolName,
181
+ level: params.level,
182
+ action: params.action,
183
+ detector: params.detector,
184
+ count: params.count,
185
+ message: params.message,
186
+ pairedToolName: params.pairedToolName,
187
+ });
188
+ markActivity();
189
+ }
168
190
  export function logRunAttempt(params) {
169
191
  diag.debug(`run attempt: sessionId=${params.sessionId ?? "unknown"} sessionKey=${params.sessionKey ?? "unknown"} runId=${params.runId} attempt=${params.attempt}`);
170
192
  emitDiagnosticEvent({
@@ -13,6 +13,7 @@ function sanitizeAndNormalizeEmbedding(vec) {
13
13
  }
14
14
  return sanitized.map((value) => value / magnitude);
15
15
  }
16
+ const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage"];
16
17
  export const DEFAULT_LOCAL_MODEL = "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf";
17
18
  function canAutoSelectLocal(options) {
18
19
  const modelPath = options.local?.modelPath?.trim();
@@ -105,7 +106,7 @@ export async function createEmbeddingProvider(options) {
105
106
  localError = formatLocalSetupError(err);
106
107
  }
107
108
  }
108
- for (const provider of ["openai", "gemini", "voyage"]) {
109
+ for (const provider of REMOTE_EMBEDDING_PROVIDER_IDS) {
109
110
  try {
110
111
  const result = await createProvider(provider);
111
112
  return { ...result, requestedProvider };
@@ -116,14 +117,18 @@ export async function createEmbeddingProvider(options) {
116
117
  missingKeyErrors.push(message);
117
118
  continue;
118
119
  }
120
+ // Non-auth errors (e.g., network) are still fatal
119
121
  throw new Error(message, { cause: err });
120
122
  }
121
123
  }
124
+ // All providers failed due to missing API keys - return null provider for FTS-only mode
122
125
  const details = [...missingKeyErrors, localError].filter(Boolean);
123
- if (details.length > 0) {
124
- throw new Error(details.join("\n\n"));
125
- }
126
- throw new Error("No embeddings provider available.");
126
+ const reason = details.length > 0 ? details.join("\n\n") : "No embeddings provider available.";
127
+ return {
128
+ provider: null,
129
+ requestedProvider,
130
+ providerUnavailableReason: reason,
131
+ };
127
132
  }
128
133
  try {
129
134
  const primary = await createProvider(requestedProvider);
@@ -142,15 +147,38 @@ export async function createEmbeddingProvider(options) {
142
147
  };
143
148
  }
144
149
  catch (fallbackErr) {
145
- throw new Error(`${reason}\n\nFallback to ${fallback} failed: ${formatErrorMessage(fallbackErr)}`, { cause: fallbackErr });
150
+ // Both primary and fallback failed - check if it's auth-related
151
+ const fallbackReason = formatErrorMessage(fallbackErr);
152
+ const combinedReason = `${reason}\n\nFallback to ${fallback} failed: ${fallbackReason}`;
153
+ if (isMissingApiKeyError(primaryErr) && isMissingApiKeyError(fallbackErr)) {
154
+ // Both failed due to missing API keys - return null for FTS-only mode
155
+ return {
156
+ provider: null,
157
+ requestedProvider,
158
+ fallbackFrom: requestedProvider,
159
+ fallbackReason: reason,
160
+ providerUnavailableReason: combinedReason,
161
+ };
162
+ }
163
+ // Non-auth errors are still fatal
164
+ throw new Error(combinedReason, { cause: fallbackErr });
146
165
  }
147
166
  }
167
+ // No fallback configured - check if we should degrade to FTS-only
168
+ if (isMissingApiKeyError(primaryErr)) {
169
+ return {
170
+ provider: null,
171
+ requestedProvider,
172
+ providerUnavailableReason: reason,
173
+ };
174
+ }
148
175
  throw new Error(reason, { cause: primaryErr });
149
176
  }
150
177
  }
151
178
  function isNodeLlamaCppMissing(err) {
152
- if (!(err instanceof Error))
179
+ if (!(err instanceof Error)) {
153
180
  return false;
181
+ }
154
182
  const code = err.code;
155
183
  if (code === "ERR_MODULE_NOT_FOUND") {
156
184
  return err.message.includes("node-llama-cpp");
@@ -174,8 +202,7 @@ function formatLocalSetupError(err) {
174
202
  ? "2) Reinstall Poolbot (this should install node-llama-cpp): npm i -g poolbot@latest"
175
203
  : null,
176
204
  "3) If you use pnpm: pnpm approve-builds (select node-llama-cpp), then pnpm rebuild node-llama-cpp",
177
- 'Or set agents.defaults.memorySearch.provider = "openai" (remote).',
178
- 'Or set agents.defaults.memorySearch.provider = "voyage" (remote).',
205
+ ...REMOTE_EMBEDDING_PROVIDER_IDS.map((provider) => `Or set agents.defaults.memorySearch.provider = "${provider}" (remote).`),
179
206
  ]
180
207
  .filter(Boolean)
181
208
  .join("\n");
@@ -1,10 +1,15 @@
1
+ import { applyMMRToHybridResults, DEFAULT_MMR_CONFIG } from "./mmr.js";
2
+ import { applyTemporalDecayToHybridResults, DEFAULT_TEMPORAL_DECAY_CONFIG, } from "./temporal-decay.js";
3
+ export { DEFAULT_MMR_CONFIG };
4
+ export { DEFAULT_TEMPORAL_DECAY_CONFIG };
1
5
  export function buildFtsQuery(raw) {
2
6
  const tokens = raw
3
- .match(/[A-Za-z0-9_]+/g)
7
+ .match(/[\p{L}\p{N}_]+/gu)
4
8
  ?.map((t) => t.trim())
5
9
  .filter(Boolean) ?? [];
6
- if (tokens.length === 0)
10
+ if (tokens.length === 0) {
7
11
  return null;
12
+ }
8
13
  const quoted = tokens.map((t) => `"${t.replaceAll('"', "")}"`);
9
14
  return quoted.join(" AND ");
10
15
  }
@@ -12,7 +17,7 @@ export function bm25RankToScore(rank) {
12
17
  const normalized = Number.isFinite(rank) ? Math.max(0, rank) : 999;
13
18
  return 1 / (1 + normalized);
14
19
  }
15
- export function mergeHybridResults(params) {
20
+ export async function mergeHybridResults(params) {
16
21
  const byId = new Map();
17
22
  for (const r of params.vector) {
18
23
  byId.set(r.id, {
@@ -30,8 +35,9 @@ export function mergeHybridResults(params) {
30
35
  const existing = byId.get(r.id);
31
36
  if (existing) {
32
37
  existing.textScore = r.textScore;
33
- if (r.snippet && r.snippet.length > 0)
38
+ if (r.snippet && r.snippet.length > 0) {
34
39
  existing.snippet = r.snippet;
40
+ }
35
41
  }
36
42
  else {
37
43
  byId.set(r.id, {
@@ -57,5 +63,18 @@ export function mergeHybridResults(params) {
57
63
  source: entry.source,
58
64
  };
59
65
  });
60
- return merged.sort((a, b) => b.score - a.score);
66
+ const temporalDecayConfig = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...params.temporalDecay };
67
+ const decayed = await applyTemporalDecayToHybridResults({
68
+ results: merged,
69
+ temporalDecay: temporalDecayConfig,
70
+ workspaceDir: params.workspaceDir,
71
+ nowMs: params.nowMs,
72
+ });
73
+ const sorted = decayed.toSorted((a, b) => b.score - a.score);
74
+ // Apply MMR re-ranking if enabled
75
+ const mmrConfig = { ...DEFAULT_MMR_CONFIG, ...params.mmr };
76
+ if (mmrConfig.enabled) {
77
+ return applyMMRToHybridResults(sorted, mmrConfig);
78
+ }
79
+ return sorted;
61
80
  }
@@ -20,6 +20,7 @@ import { buildFileEntry, chunkMarkdown, ensureDir, hashText, isMemoryPath, listM
20
20
  import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
21
21
  import { searchKeyword, searchVector } from "./manager-search.js";
22
22
  import { ensureMemoryIndexSchema } from "./memory-schema.js";
23
+ import { extractKeywords } from "./query-expansion.js";
23
24
  import { requireNodeSqlite } from "./sqlite.js";
24
25
  import { loadSqliteVecExtension } from "./sqlite-vec.js";
25
26
  const META_KEY = "memory_index_meta_v1";
@@ -54,6 +55,7 @@ export class MemoryIndexManager {
54
55
  requestedProvider;
55
56
  fallbackFrom;
56
57
  fallbackReason;
58
+ providerUnavailableReason;
57
59
  openAi;
58
60
  gemini;
59
61
  voyage;
@@ -108,6 +110,7 @@ export class MemoryIndexManager {
108
110
  workspaceDir,
109
111
  settings,
110
112
  providerResult,
113
+ purpose: params.purpose,
111
114
  });
112
115
  INDEX_CACHE.set(key, manager);
113
116
  return manager;
@@ -122,6 +125,7 @@ export class MemoryIndexManager {
122
125
  this.requestedProvider = params.providerResult.requestedProvider;
123
126
  this.fallbackFrom = params.providerResult.fallbackFrom;
124
127
  this.fallbackReason = params.providerResult.fallbackReason;
128
+ this.providerUnavailableReason = params.providerResult.providerUnavailableReason;
125
129
  this.openAi = params.providerResult.openAi;
126
130
  this.gemini = params.providerResult.gemini;
127
131
  this.voyage = params.providerResult.voyage;
@@ -146,7 +150,8 @@ export class MemoryIndexManager {
146
150
  this.ensureWatcher();
147
151
  this.ensureSessionListener();
148
152
  this.ensureIntervalSync();
149
- this.dirty = this.sources.has("memory");
153
+ const statusOnly = params.purpose === "status";
154
+ this.dirty = this.sources.has("memory") && (statusOnly ? !meta : true);
150
155
  this.batch = this.resolveBatchConfig();
151
156
  }
152
157
  async warmSession(sessionKey) {
@@ -175,6 +180,30 @@ export class MemoryIndexManager {
175
180
  const maxResults = opts?.maxResults ?? this.settings.query.maxResults;
176
181
  const hybrid = this.settings.query.hybrid;
177
182
  const candidates = Math.min(200, Math.max(1, Math.floor(maxResults * hybrid.candidateMultiplier)));
183
+ // FTS-only mode: no embedding provider available
184
+ if (!this.provider) {
185
+ if (!this.fts.enabled || !this.fts.available) {
186
+ log.warn("memory search: no provider and FTS unavailable");
187
+ return [];
188
+ }
189
+ const keywords = extractKeywords(cleaned);
190
+ const searchTerms = keywords.length > 0 ? keywords : [cleaned];
191
+ const resultSets = await Promise.all(searchTerms.map((term) => this.searchKeyword(term, candidates).catch(() => [])));
192
+ const seenIds = new Map();
193
+ for (const results of resultSets) {
194
+ for (const result of results) {
195
+ const existing = seenIds.get(result.id);
196
+ if (!existing || result.score > existing.score) {
197
+ seenIds.set(result.id, result);
198
+ }
199
+ }
200
+ }
201
+ const merged = [...seenIds.values()]
202
+ .toSorted((a, b) => b.score - a.score)
203
+ .filter((entry) => entry.score >= minScore)
204
+ .slice(0, maxResults);
205
+ return merged;
206
+ }
178
207
  const keywordResults = hybrid.enabled
179
208
  ? await this.searchKeyword(cleaned, candidates).catch(() => [])
180
209
  : [];
@@ -186,15 +215,19 @@ export class MemoryIndexManager {
186
215
  if (!hybrid.enabled) {
187
216
  return vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults);
188
217
  }
189
- const merged = this.mergeHybridResults({
218
+ const merged = await this.mergeHybridResults({
190
219
  vector: vectorResults,
191
220
  keyword: keywordResults,
192
221
  vectorWeight: hybrid.vectorWeight,
193
222
  textWeight: hybrid.textWeight,
223
+ mmr: hybrid.mmr,
224
+ temporalDecay: hybrid.temporalDecay,
194
225
  });
195
226
  return merged.filter((entry) => entry.score >= minScore).slice(0, maxResults);
196
227
  }
197
228
  async searchVector(queryVec, limit) {
229
+ if (!this.provider)
230
+ return [];
198
231
  const results = await searchVector({
199
232
  db: this.db,
200
233
  vectorTable: VECTOR_TABLE,
@@ -218,7 +251,7 @@ export class MemoryIndexManager {
218
251
  const results = await searchKeyword({
219
252
  db: this.db,
220
253
  ftsTable: FTS_TABLE,
221
- providerModel: this.provider.model,
254
+ providerModel: this.provider?.model ?? "fts-only",
222
255
  query,
223
256
  limit,
224
257
  snippetMaxChars: SNIPPET_MAX_CHARS,
@@ -228,8 +261,8 @@ export class MemoryIndexManager {
228
261
  });
229
262
  return results.map((entry) => entry);
230
263
  }
231
- mergeHybridResults(params) {
232
- const merged = mergeHybridResults({
264
+ async mergeHybridResults(params) {
265
+ const merged = await mergeHybridResults({
233
266
  vector: params.vector.map((r) => ({
234
267
  id: r.id,
235
268
  path: r.path,
@@ -250,6 +283,9 @@ export class MemoryIndexManager {
250
283
  })),
251
284
  vectorWeight: params.vectorWeight,
252
285
  textWeight: params.textWeight,
286
+ workspaceDir: this.workspaceDir,
287
+ mmr: params.mmr,
288
+ temporalDecay: params.temporalDecay,
253
289
  });
254
290
  return merged.map((entry) => entry);
255
291
  }
@@ -359,8 +395,8 @@ export class MemoryIndexManager {
359
395
  dirty: this.dirty || this.sessionsDirty,
360
396
  workspaceDir: this.workspaceDir,
361
397
  dbPath: this.settings.store.path,
362
- provider: this.provider.id,
363
- model: this.provider.model,
398
+ provider: this.provider?.id ?? "none",
399
+ model: this.provider?.model,
364
400
  requestedProvider: this.requestedProvider,
365
401
  sources: Array.from(this.sources),
366
402
  extraPaths: this.settings.extraPaths,
@@ -882,7 +918,7 @@ export class MemoryIndexManager {
882
918
  try {
883
919
  this.db
884
920
  .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
885
- .run(stale.path, "memory", this.provider.model);
921
+ .run(stale.path, "memory", this.provider?.model ?? "fts-only");
886
922
  }
887
923
  catch { }
888
924
  }
@@ -976,7 +1012,7 @@ export class MemoryIndexManager {
976
1012
  try {
977
1013
  this.db
978
1014
  .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
979
- .run(stale.path, "sessions", this.provider.model);
1015
+ .run(stale.path, "sessions", this.provider?.model ?? "fts-only");
980
1016
  }
981
1017
  catch { }
982
1018
  }
@@ -1015,8 +1051,8 @@ export class MemoryIndexManager {
1015
1051
  const meta = this.readMeta();
1016
1052
  const needsFullReindex = params?.force ||
1017
1053
  !meta ||
1018
- meta.model !== this.provider.model ||
1019
- meta.provider !== this.provider.id ||
1054
+ meta.model !== this.provider?.model ||
1055
+ meta.provider !== this.provider?.id ||
1020
1056
  meta.providerKey !== this.providerKey ||
1021
1057
  meta.chunkTokens !== this.settings.chunking.tokens ||
1022
1058
  meta.chunkOverlap !== this.settings.chunking.overlap ||
@@ -1068,9 +1104,9 @@ export class MemoryIndexManager {
1068
1104
  resolveBatchConfig() {
1069
1105
  const batch = this.settings.remote?.batch;
1070
1106
  const enabled = Boolean(batch?.enabled &&
1071
- ((this.openAi && this.provider.id === "openai") ||
1072
- (this.gemini && this.provider.id === "gemini") ||
1073
- (this.voyage && this.provider.id === "voyage")));
1107
+ ((this.openAi && this.provider?.id === "openai") ||
1108
+ (this.gemini && this.provider?.id === "gemini") ||
1109
+ (this.voyage && this.provider?.id === "voyage")));
1074
1110
  return {
1075
1111
  enabled,
1076
1112
  wait: batch?.wait ?? true,
@@ -1081,7 +1117,7 @@ export class MemoryIndexManager {
1081
1117
  }
1082
1118
  async activateFallbackProvider(reason) {
1083
1119
  const fallback = this.settings.fallback;
1084
- if (!fallback || fallback === "none" || fallback === this.provider.id)
1120
+ if (!fallback || fallback === "none" || !this.provider || fallback === this.provider.id)
1085
1121
  return false;
1086
1122
  if (this.fallbackFrom)
1087
1123
  return false;
@@ -1170,8 +1206,8 @@ export class MemoryIndexManager {
1170
1206
  this.sessionsDirty = false;
1171
1207
  }
1172
1208
  nextMeta = {
1173
- model: this.provider.model,
1174
- provider: this.provider.id,
1209
+ model: this.provider?.model ?? "fts-only",
1210
+ provider: this.provider?.id ?? "none",
1175
1211
  providerKey: this.providerKey,
1176
1212
  chunkTokens: this.settings.chunking.tokens,
1177
1213
  chunkOverlap: this.settings.chunking.overlap,
@@ -1371,7 +1407,11 @@ export class MemoryIndexManager {
1371
1407
  if (unique.length === 0)
1372
1408
  return new Map();
1373
1409
  const out = new Map();
1374
- const baseParams = [this.provider.id, this.provider.model, this.providerKey];
1410
+ const baseParams = [
1411
+ this.provider?.id ?? "none",
1412
+ this.provider?.model ?? "fts-only",
1413
+ this.providerKey,
1414
+ ];
1375
1415
  const batchSize = 400;
1376
1416
  for (let start = 0; start < unique.length; start += batchSize) {
1377
1417
  const batch = unique.slice(start, start + batchSize);
@@ -1387,7 +1427,7 @@ export class MemoryIndexManager {
1387
1427
  return out;
1388
1428
  }
1389
1429
  upsertEmbeddingCache(entries) {
1390
- if (!this.cache.enabled)
1430
+ if (!this.cache.enabled || !this.provider)
1391
1431
  return;
1392
1432
  if (entries.length === 0)
1393
1433
  return;
@@ -1461,6 +1501,9 @@ export class MemoryIndexManager {
1461
1501
  return embeddings;
1462
1502
  }
1463
1503
  computeProviderKey() {
1504
+ if (!this.provider) {
1505
+ return hashText(JSON.stringify({ provider: "none", model: "fts-only" }));
1506
+ }
1464
1507
  if (this.provider.id === "openai" && this.openAi) {
1465
1508
  const entries = Object.entries(this.openAi.headers)
1466
1509
  .filter(([key]) => key.toLowerCase() !== "authorization")
@@ -1491,13 +1534,13 @@ export class MemoryIndexManager {
1491
1534
  return hashText(JSON.stringify({ provider: this.provider.id, model: this.provider.model }));
1492
1535
  }
1493
1536
  async embedChunksWithBatch(chunks, entry, source) {
1494
- if (this.provider.id === "openai" && this.openAi) {
1537
+ if (this.provider?.id === "openai" && this.openAi) {
1495
1538
  return this.embedChunksWithOpenAiBatch(chunks, entry, source);
1496
1539
  }
1497
- if (this.provider.id === "gemini" && this.gemini) {
1540
+ if (this.provider?.id === "gemini" && this.gemini) {
1498
1541
  return this.embedChunksWithGeminiBatch(chunks, entry, source);
1499
1542
  }
1500
- if (this.provider.id === "voyage" && this.voyage) {
1543
+ if (this.provider?.id === "voyage" && this.voyage) {
1501
1544
  return this.embedChunksWithVoyageBatch(chunks, entry, source);
1502
1545
  }
1503
1546
  return this.embedChunksInBatches(chunks);
@@ -1602,7 +1645,7 @@ export class MemoryIndexManager {
1602
1645
  method: "POST",
1603
1646
  url: OPENAI_BATCH_ENDPOINT,
1604
1647
  body: {
1605
- model: this.openAi?.model ?? this.provider.model,
1648
+ model: this.openAi?.model ?? this.provider?.model ?? "fts-only",
1606
1649
  input: chunk.text,
1607
1650
  },
1608
1651
  });
@@ -1700,6 +1743,8 @@ export class MemoryIndexManager {
1700
1743
  async embedBatchWithRetry(texts) {
1701
1744
  if (texts.length === 0)
1702
1745
  return [];
1746
+ if (!this.provider)
1747
+ throw new Error("embedding provider unavailable");
1703
1748
  let attempt = 0;
1704
1749
  let delayMs = EMBEDDING_RETRY_BASE_DELAY_MS;
1705
1750
  while (true) {
@@ -1729,13 +1774,15 @@ export class MemoryIndexManager {
1729
1774
  return /(rate[_ ]limit|too many requests|429|resource has been exhausted|5\d\d|cloudflare)/i.test(message);
1730
1775
  }
1731
1776
  resolveEmbeddingTimeout(kind) {
1732
- const isLocal = this.provider.id === "local";
1777
+ const isLocal = this.provider?.id === "local";
1733
1778
  if (kind === "query") {
1734
1779
  return isLocal ? EMBEDDING_QUERY_TIMEOUT_LOCAL_MS : EMBEDDING_QUERY_TIMEOUT_REMOTE_MS;
1735
1780
  }
1736
1781
  return isLocal ? EMBEDDING_BATCH_TIMEOUT_LOCAL_MS : EMBEDDING_BATCH_TIMEOUT_REMOTE_MS;
1737
1782
  }
1738
1783
  async embedQueryWithTimeout(text) {
1784
+ if (!this.provider)
1785
+ throw new Error("embedding provider unavailable");
1739
1786
  const timeoutMs = this.resolveEmbeddingTimeout("query");
1740
1787
  log.debug("memory embeddings: query start", { provider: this.provider.id, timeoutMs });
1741
1788
  return await this.withTimeout(this.provider.embedQuery(text), timeoutMs, `memory embeddings query timed out after ${Math.round(timeoutMs / 1000)}s`);
@@ -1873,7 +1920,7 @@ export class MemoryIndexManager {
1873
1920
  try {
1874
1921
  this.db
1875
1922
  .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
1876
- .run(entry.path, options.source, this.provider.model);
1923
+ .run(entry.path, options.source, this.provider?.model ?? "fts-only");
1877
1924
  }
1878
1925
  catch { }
1879
1926
  }
@@ -1883,7 +1930,8 @@ export class MemoryIndexManager {
1883
1930
  for (let i = 0; i < chunks.length; i++) {
1884
1931
  const chunk = chunks[i];
1885
1932
  const embedding = embeddings[i] ?? [];
1886
- const id = hashText(`${options.source}:${entry.path}:${chunk.startLine}:${chunk.endLine}:${chunk.hash}:${this.provider.model}`);
1933
+ const providerModel = this.provider?.model ?? "fts-only";
1934
+ const id = hashText(`${options.source}:${entry.path}:${chunk.startLine}:${chunk.endLine}:${chunk.hash}:${providerModel}`);
1887
1935
  this.db
1888
1936
  .prepare(`INSERT INTO chunks (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at)
1889
1937
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -1893,7 +1941,7 @@ export class MemoryIndexManager {
1893
1941
  text=excluded.text,
1894
1942
  embedding=excluded.embedding,
1895
1943
  updated_at=excluded.updated_at`)
1896
- .run(id, entry.path, options.source, chunk.startLine, chunk.endLine, chunk.hash, this.provider.model, chunk.text, JSON.stringify(embedding), now);
1944
+ .run(id, entry.path, options.source, chunk.startLine, chunk.endLine, chunk.hash, providerModel, chunk.text, JSON.stringify(embedding), now);
1897
1945
  if (vectorReady && embedding.length > 0) {
1898
1946
  try {
1899
1947
  this.db.prepare(`DELETE FROM ${VECTOR_TABLE} WHERE id = ?`).run(id);
@@ -1907,7 +1955,7 @@ export class MemoryIndexManager {
1907
1955
  this.db
1908
1956
  .prepare(`INSERT INTO ${FTS_TABLE} (text, id, path, source, model, start_line, end_line)\n` +
1909
1957
  ` VALUES (?, ?, ?, ?, ?, ?, ?)`)
1910
- .run(chunk.text, id, entry.path, options.source, this.provider.model, chunk.startLine, chunk.endLine);
1958
+ .run(chunk.text, id, entry.path, options.source, providerModel, chunk.startLine, chunk.endLine);
1911
1959
  }
1912
1960
  }
1913
1961
  this.db