@poolzin/pool-bot 2026.1.29 → 2026.1.31

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 (39) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +11 -0
  3. package/dist/agents/system-prompt.js +16 -16
  4. package/dist/agents/tools/memory-tool.js +2 -1
  5. package/dist/build-info.json +3 -3
  6. package/dist/cli/program/command-registry.js +5 -0
  7. package/dist/cli/program/register.completion.js +355 -0
  8. package/dist/gateway/hooks/index.js +49 -0
  9. package/dist/gateway/hooks/lifecycle-hooks-integration.js +256 -0
  10. package/dist/gateway/hooks/lifecycle-hooks.js +236 -0
  11. package/dist/gateway/hooks/progressive-disclosure-details.js +237 -0
  12. package/dist/gateway/hooks/progressive-disclosure-index.js +354 -0
  13. package/dist/gateway/hooks/progressive-disclosure-timeline.js +231 -0
  14. package/dist/gateway/hooks/progressive-disclosure-types.js +65 -0
  15. package/dist/gateway/hooks/progressive-disclosure.js +242 -0
  16. package/dist/gateway/hooks/tool-usage-capture.js +253 -0
  17. package/dist/gateway/hooks/tool-usage-storage.js +144 -0
  18. package/dist/gateway/server-methods/nodes.js +2 -0
  19. package/dist/gateway/server.impl.js +4 -0
  20. package/dist/imessage/monitor/monitor-provider.js +14 -1
  21. package/dist/media/store.js +37 -1
  22. package/dist/memory/index.js +5 -0
  23. package/dist/memory/manager.js +25 -2
  24. package/docs/WHATSAPP-HEARTBEAT-TROUBLESHOOTING.md +319 -0
  25. package/package.json +1 -1
  26. package/skills/webgpu-threejs-tsl/REFERENCE.md +283 -0
  27. package/skills/webgpu-threejs-tsl/SKILL.md +91 -0
  28. package/skills/webgpu-threejs-tsl/docs/compute-shaders.md +404 -0
  29. package/skills/webgpu-threejs-tsl/docs/core-concepts.md +453 -0
  30. package/skills/webgpu-threejs-tsl/docs/materials.md +353 -0
  31. package/skills/webgpu-threejs-tsl/docs/post-processing.md +434 -0
  32. package/skills/webgpu-threejs-tsl/docs/wgsl-integration.md +324 -0
  33. package/skills/webgpu-threejs-tsl/examples/basic-setup.js +87 -0
  34. package/skills/webgpu-threejs-tsl/examples/custom-material.js +170 -0
  35. package/skills/webgpu-threejs-tsl/examples/earth-shader.js +292 -0
  36. package/skills/webgpu-threejs-tsl/examples/particle-system.js +259 -0
  37. package/skills/webgpu-threejs-tsl/examples/post-processing.js +199 -0
  38. package/skills/webgpu-threejs-tsl/templates/compute-shader.js +305 -0
  39. package/skills/webgpu-threejs-tsl/templates/webgpu-project.js +276 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Tool Usage Capture Hook
3
+ *
4
+ * Captures tool usage automatically via lifecycle hooks.
5
+ * Safe: never throws, async operations, feature-flagged.
6
+ *
7
+ * Integration:
8
+ * - Registers as onToolUse hook
9
+ * - Captures all tool calls automatically
10
+ * - Stores compressed data in SessionEntry
11
+ */
12
+ import { TOOL_USAGE_ENABLED, TOOL_USAGE_MAX_RECORDS, createToolUsageRecord, updateToolStats, addToHistory, } from "./tool-usage-storage.js";
13
+ // ============================================================================
14
+ // Configuration
15
+ // ============================================================================
16
+ const TOOL_USAGE_BUFFER_FLUSH_INTERVAL = 30000; // 30 seconds
17
+ const TOOL_USAGE_BUFFER_MAX_SIZE = 50; // Flush after 50 tool calls
18
+ const toolUsageBuffer = new Map();
19
+ let bufferFlushScheduled = false;
20
+ // ============================================================================
21
+ // SessionEntry Extension (Type-safe, backward compatible)
22
+ // ============================================================================
23
+ /**
24
+ * Load tool usage from SessionEntry
25
+ * Safe: handles missing data, validates structure
26
+ */
27
+ export async function loadSessionToolUsage(sessionKey) {
28
+ try {
29
+ // TODO: Integrate with actual SessionEntry in Phase 4
30
+ // For now, return in-memory placeholder
31
+ const existing = toolUsageBuffer.get(sessionKey);
32
+ if (existing && existing.length > 0) {
33
+ // Return computed stats from buffer
34
+ const tools = {};
35
+ for (const record of existing) {
36
+ if (!tools[record.toolName]) {
37
+ tools[record.toolName] = {
38
+ count: 0,
39
+ totalMs: 0,
40
+ avgMs: 0,
41
+ lastUsed: record.timestamp,
42
+ };
43
+ }
44
+ tools[record.toolName] = updateToolStats(tools[record.toolName], record.executionTimeMs);
45
+ }
46
+ return {
47
+ version: 2,
48
+ sessionKey,
49
+ sessionId: sessionKey, // Placeholder
50
+ tools,
51
+ history: existing,
52
+ lastCaptured: Date.now(),
53
+ };
54
+ }
55
+ // Return empty usage
56
+ return {
57
+ version: 2,
58
+ sessionKey,
59
+ sessionId: sessionKey,
60
+ tools: {},
61
+ history: [],
62
+ lastCaptured: Date.now(),
63
+ };
64
+ }
65
+ catch (err) {
66
+ console.error("[tool-usage-capture] Failed to load tool usage:", err);
67
+ // Return empty usage on error
68
+ return {
69
+ version: 2,
70
+ sessionKey,
71
+ sessionId: sessionKey,
72
+ tools: {},
73
+ history: [],
74
+ lastCaptured: Date.now(),
75
+ };
76
+ }
77
+ }
78
+ /**
79
+ * Save tool usage to SessionEntry
80
+ * Safe: validates data, handles errors gracefully
81
+ */
82
+ export async function saveSessionToolUsage(sessionKey, usage) {
83
+ try {
84
+ // TODO: Integrate with actual SessionEntry in Phase 4
85
+ // For now, just update in-memory buffer
86
+ const existing = toolUsageBuffer.get(sessionKey) || [];
87
+ const newRecords = usage.history.slice(existing.length);
88
+ if (newRecords.length > 0) {
89
+ toolUsageBuffer.set(sessionKey, [...existing, ...newRecords]);
90
+ }
91
+ if (TOOL_USAGE_ENABLED) {
92
+ console.log(`[tool-usage-capture] Saved ${newRecords.length} records for ${sessionKey}`);
93
+ }
94
+ }
95
+ catch (err) {
96
+ console.error("[tool-usage-capture] Failed to save tool usage:", err);
97
+ // Never throw - errors are logged only
98
+ }
99
+ }
100
+ // ============================================================================
101
+ // Hook Implementation
102
+ // ============================================================================
103
+ /**
104
+ * Main hook: captures tool usage automatically
105
+ * Safe: validates all inputs, never throws, handles all edge cases
106
+ */
107
+ export const toolUsageCaptureHook = async (ctx) => {
108
+ // Feature flag check (safe exit)
109
+ if (!TOOL_USAGE_ENABLED) {
110
+ return;
111
+ }
112
+ try {
113
+ // Validate inputs (safe defaults)
114
+ const toolName = ctx.toolName || "unknown_tool";
115
+ const executionTimeMs = ctx.executionTimeMs || 0;
116
+ const success = ctx.success !== undefined ? ctx.success : true;
117
+ // Create compressed record
118
+ const record = createToolUsageRecord(toolName, ctx.toolInput, ctx.toolOutput, executionTimeMs, success, ctx.error);
119
+ // Load existing usage
120
+ const usage = await loadSessionToolUsage(ctx.sessionKey);
121
+ // Update stats
122
+ if (!usage.tools[toolName]) {
123
+ usage.tools[toolName] = {
124
+ count: 0,
125
+ totalMs: 0,
126
+ avgMs: 0,
127
+ lastUsed: record.timestamp,
128
+ };
129
+ }
130
+ usage.tools[toolName] = updateToolStats(usage.tools[toolName], executionTimeMs);
131
+ // Add to history (respects max limit)
132
+ usage.history = addToHistory(usage.history, record, TOOL_USAGE_MAX_RECORDS);
133
+ usage.lastCaptured = Date.now();
134
+ // Save (async, non-blocking)
135
+ await saveSessionToolUsage(ctx.sessionKey, usage).catch((err) => {
136
+ // Save errors are logged in saveSessionToolUsage
137
+ // This catch is just for extra safety
138
+ console.error("[tool-usage-capture] Unhandled save error:", err);
139
+ });
140
+ // Debug logging (can be disabled)
141
+ if (success && TOOL_USAGE_ENABLED) {
142
+ console.log(`[tool-usage-capture] ${toolName} (${executionTimeMs}ms) - ${ctx.sessionKey}`);
143
+ }
144
+ }
145
+ catch (err) {
146
+ // NEVER throw from hook - would break the tool call
147
+ console.error("[tool-usage-capture] Hook error:", err);
148
+ }
149
+ };
150
+ // ============================================================================
151
+ // Buffer Management (performance optimization)
152
+ // ============================================================================
153
+ /**
154
+ * Flush in-memory buffer to storage
155
+ * Safe: handles errors, never throws
156
+ */
157
+ export async function flushToolUsageBuffer() {
158
+ try {
159
+ if (toolUsageBuffer.size === 0) {
160
+ return; // Nothing to flush
161
+ }
162
+ let flushed = 0;
163
+ for (const [sessionKey, records] of toolUsageBuffer.entries()) {
164
+ try {
165
+ // TODO: Actually persist to SessionEntry in Phase 4
166
+ // For now, buffer stays in-memory
167
+ flushed += records.length;
168
+ }
169
+ catch (err) {
170
+ console.error(`[tool-usage-capture] Failed to flush buffer for ${sessionKey}:`, err);
171
+ }
172
+ }
173
+ if (flushed > 0 && TOOL_USAGE_ENABLED) {
174
+ console.log(`[tool-usage-capture] Flushed ${flushed} records from buffer`);
175
+ }
176
+ }
177
+ catch (err) {
178
+ console.error("[tool-usage-capture] Buffer flush error:", err);
179
+ }
180
+ }
181
+ /**
182
+ * Schedule buffer flush
183
+ * Safe: debounced, only schedules once
184
+ */
185
+ function scheduleBufferFlush() {
186
+ if (bufferFlushScheduled) {
187
+ return; // Already scheduled
188
+ }
189
+ bufferFlushScheduled = true;
190
+ // Flush after 30 seconds
191
+ setTimeout(async () => {
192
+ await flushToolUsageBuffer();
193
+ bufferFlushScheduled = false;
194
+ }, TOOL_USAGE_BUFFER_FLUSH_INTERVAL);
195
+ }
196
+ // ============================================================================
197
+ // Query API (for future use)
198
+ // ============================================================================
199
+ /**
200
+ * Get tool usage statistics for a session
201
+ * Safe: returns empty object if no data
202
+ */
203
+ export async function getSessionToolUsage(sessionKey) {
204
+ try {
205
+ const usage = await loadSessionToolUsage(sessionKey);
206
+ return usage;
207
+ }
208
+ catch (err) {
209
+ console.error(`[tool-usage-capture] Failed to get usage for ${sessionKey}:`, err);
210
+ return undefined;
211
+ }
212
+ }
213
+ /**
214
+ * Get recent tool usage (last N records)
215
+ * Safe: returns empty array if no data
216
+ */
217
+ export async function getRecentToolUsage(sessionKey, count = 10) {
218
+ try {
219
+ const usage = await loadSessionToolUsage(sessionKey);
220
+ return usage.history.slice(-count);
221
+ }
222
+ catch (err) {
223
+ console.error(`[tool-usage-capture] Failed to get recent usage for ${sessionKey}:`, err);
224
+ return [];
225
+ }
226
+ }
227
+ // ============================================================================
228
+ // Cleanup
229
+ // ============================================================================
230
+ /**
231
+ * Clear tool usage buffer (for testing)
232
+ * Safe: never throws
233
+ */
234
+ export function clearToolUsageBuffer() {
235
+ try {
236
+ toolUsageBuffer.clear();
237
+ }
238
+ catch (err) {
239
+ console.error("[tool-usage-capture] Buffer clear error:", err);
240
+ }
241
+ }
242
+ /**
243
+ * Clear tool usage for a specific session
244
+ * Safe: never throws
245
+ */
246
+ export function clearSessionToolUsage(sessionKey) {
247
+ try {
248
+ toolUsageBuffer.delete(sessionKey);
249
+ }
250
+ catch (err) {
251
+ console.error(`[tool-usage-capture] Failed to clear usage for ${sessionKey}:`, err);
252
+ }
253
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Tool Usage Capture - Storage Layer
3
+ *
4
+ * Safe, efficient storage for tool usage data.
5
+ * Phase 2a: Type definitions and interfaces (NO breaking changes)
6
+ *
7
+ * Safety measures:
8
+ * - All fields optional (backward compatible)
9
+ * - Version field for future migrations
10
+ * - No modifications to existing SessionEntry fields
11
+ */
12
+ // ============================================================================
13
+ // Constants
14
+ // ============================================================================
15
+ export const TOOL_USAGE_MAX_RECORDS = 100; // Keep only last 100 records
16
+ export const TOOL_USAGE_MAX_SUMMARY_LEN = 100; // Max 100 chars per summary
17
+ export const TOOL_USAGE_ENABLED = true; // Feature flag (master switch)
18
+ // ============================================================================
19
+ // Compression Functions (safe, no side effects)
20
+ // ============================================================================
21
+ /**
22
+ * Compress a tool input/output value to a short summary
23
+ * Safe: never throws, handles all types gracefully
24
+ */
25
+ export function compressToolValue(value, maxLen = TOOL_USAGE_MAX_SUMMARY_LEN) {
26
+ try {
27
+ // Handle primitives
28
+ if (value === null)
29
+ return "null";
30
+ if (value === undefined)
31
+ return "undefined";
32
+ if (typeof value === "string") {
33
+ return value.length > maxLen ? value.slice(0, maxLen) + "..." : value;
34
+ }
35
+ if (typeof value === "number")
36
+ return String(value);
37
+ if (typeof value === "boolean")
38
+ return String(value);
39
+ // Handle objects and arrays
40
+ if (typeof value === "object") {
41
+ const json = JSON.stringify(value);
42
+ return json.length > maxLen ? json.slice(0, maxLen) + "..." : json;
43
+ }
44
+ // Fallback
45
+ return String(value);
46
+ }
47
+ catch (err) {
48
+ // If compression fails, return safe fallback
49
+ return "[compression_error]";
50
+ }
51
+ }
52
+ /**
53
+ * Create a tool usage record from hook context
54
+ * Safe: validates all inputs, never throws
55
+ */
56
+ export function createToolUsageRecord(toolName, toolInput, toolOutput, executionTimeMs, success, error) {
57
+ // Validate inputs (safe defaults)
58
+ const safeToolName = typeof toolName === "string" && toolName.length > 0 ? toolName : "unknown_tool";
59
+ const safeInput = typeof toolInput === "object" && toolInput !== null ? toolInput : {};
60
+ const safeTime = typeof executionTimeMs === "number" && executionTimeMs >= 0 ? executionTimeMs : 0;
61
+ const safeSuccess = Boolean(success);
62
+ return {
63
+ toolName: safeToolName,
64
+ timestamp: Date.now(),
65
+ executionTimeMs: safeTime,
66
+ success: safeSuccess,
67
+ inputSummary: compressToolValue(safeInput),
68
+ outputSummary: compressToolValue(toolOutput),
69
+ error: error ? String(error).slice(0, 200) : undefined,
70
+ };
71
+ }
72
+ // ============================================================================
73
+ // Statistics Update (pure function, no side effects)
74
+ // ============================================================================
75
+ /**
76
+ * Update tool statistics with a new record
77
+ * Safe: never mutates input, returns new object
78
+ */
79
+ export function updateToolStats(existingStats, executionTimeMs) {
80
+ const count = (existingStats?.count ?? 0) + 1;
81
+ const totalMs = (existingStats?.totalMs ?? 0) + executionTimeMs;
82
+ const avgMs = count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0;
83
+ return {
84
+ count,
85
+ totalMs,
86
+ avgMs,
87
+ lastUsed: Date.now(),
88
+ };
89
+ }
90
+ // ============================================================================
91
+ // History Management (pure functions)
92
+ // ============================================================================
93
+ /**
94
+ * Add record to history, respecting max limit
95
+ * Safe: never exceeds max records, returns new array
96
+ */
97
+ export function addToHistory(history, record, maxRecords = TOOL_USAGE_MAX_RECORDS) {
98
+ const newHistory = [...history, record];
99
+ // Keep only last N records
100
+ if (newHistory.length > maxRecords) {
101
+ return newHistory.slice(-maxRecords);
102
+ }
103
+ return newHistory;
104
+ }
105
+ /**
106
+ * Get recent tool usage (last N records)
107
+ * Safe: never mutates input, returns slice
108
+ */
109
+ export function getRecentToolUsage(history, count = 10) {
110
+ return history.slice(-count);
111
+ }
112
+ // ============================================================================
113
+ // Query Functions (for future use)
114
+ // ============================================================================
115
+ /**
116
+ * Get statistics for a specific tool
117
+ * Safe: returns undefined if tool not found
118
+ */
119
+ export function getToolStats(usage, toolName) {
120
+ return usage?.tools[toolName];
121
+ }
122
+ /**
123
+ * Get all tool names sorted by usage frequency
124
+ * Safe: returns empty array if no data
125
+ */
126
+ export function getToolsByFrequency(usage) {
127
+ if (!usage?.tools)
128
+ return [];
129
+ return Object.entries(usage.tools)
130
+ .map(([toolName, stats]) => ({ toolName, count: stats.count }))
131
+ .sort((a, b) => b.count - a.count);
132
+ }
133
+ /**
134
+ * Get slowest tools (by average execution time)
135
+ * Safe: returns empty array if no data
136
+ */
137
+ export function getSlowestTools(usage, limit = 5) {
138
+ if (!usage?.tools)
139
+ return [];
140
+ return Object.entries(usage.tools)
141
+ .map(([toolName, stats]) => ({ toolName, avgMs: stats.avgMs }))
142
+ .sort((a, b) => b.avgMs - a.avgMs)
143
+ .slice(0, limit);
144
+ }
@@ -5,6 +5,8 @@ import { respondInvalidParams, respondUnavailableOnThrow, safeParseJson, uniqueS
5
5
  import { loadConfig } from "../../config/config.js";
6
6
  import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
7
7
  function isNodeEntry(entry) {
8
+ if (entry.clientMode === "node")
9
+ return true;
8
10
  if (entry.role === "node")
9
11
  return true;
10
12
  if (Array.isArray(entry.roles) && entry.roles.includes("node"))
@@ -27,6 +27,7 @@ import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
27
27
  import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
28
28
  import { createChannelManager } from "./server-channels.js";
29
29
  import { createAgentEventHandler } from "./server-chat.js";
30
+ import { startLifecycleHooksIntegration } from "./hooks/lifecycle-hooks-integration.js";
30
31
  import { createGatewayCloseHandler } from "./server-close.js";
31
32
  import { buildGatewayCronService } from "./server-cron.js";
32
33
  import { applyGatewayLaneConcurrency } from "./server-lanes.js";
@@ -263,6 +264,9 @@ export async function startGatewayServer(port = 18789, opts = {}) {
263
264
  resolveSessionKeyForRun,
264
265
  clearAgentRunContext,
265
266
  }));
267
+ // Lifecycle Hooks Integration (POC Day 1.5)
268
+ const lifecycleHooksUnsub = startLifecycleHooksIntegration();
269
+ logHooks.info("Lifecycle hooks integration started");
266
270
  const heartbeatUnsub = onHeartbeatEvent((evt) => {
267
271
  broadcast("heartbeat", evt, { dropIfSlow: true });
268
272
  });
@@ -108,9 +108,18 @@ export async function monitorIMessageProvider(opts = {}) {
108
108
  const inboundDebouncer = createInboundDebouncer({
109
109
  debounceMs: inboundDebounceMs,
110
110
  buildKey: (entry) => {
111
+ // Use message-specific key to preserve attachments
112
+ // This prevents different messages from being merged incorrectly
111
113
  const sender = entry.message.sender?.trim();
112
114
  if (!sender)
113
115
  return null;
116
+ // Prefer message id for unique identification (prevents cross-message contamination)
117
+ // If id is not available, fall back to conversation-based key
118
+ const messageId = entry.message.id;
119
+ if (messageId != null) {
120
+ return `imessage:${accountInfo.accountId}:msg:${messageId}`;
121
+ }
122
+ // Fallback to conversation-based key only when id is unavailable
114
123
  const conversationId = entry.message.chat_id != null
115
124
  ? `chat:${entry.message.chat_id}`
116
125
  : (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown");
@@ -120,6 +129,8 @@ export async function monitorIMessageProvider(opts = {}) {
120
129
  const text = entry.message.text?.trim() ?? "";
121
130
  if (!text)
122
131
  return false;
132
+ // Messages with attachments should not be debounced across message boundaries
133
+ // to preserve attachment context
123
134
  if (entry.message.attachments && entry.message.attachments.length > 0)
124
135
  return false;
125
136
  return !hasControlCommand(text, cfg);
@@ -139,7 +150,9 @@ export async function monitorIMessageProvider(opts = {}) {
139
150
  const syntheticMessage = {
140
151
  ...last.message,
141
152
  text: combinedText,
142
- attachments: null,
153
+ // Preserve attachments from the last message in the batch
154
+ // (should always be null here due to shouldDebounce guard, but keep for safety)
155
+ attachments: last.message.attachments ?? null,
143
156
  };
144
157
  await handleMessageNow(syntheticMessage);
145
158
  },
@@ -12,6 +12,40 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
12
12
  export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5MB default
13
13
  const MAX_BYTES = MEDIA_MAX_BYTES;
14
14
  const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
15
+ /**
16
+ * SECURITY: Safe paths for local file extraction
17
+ *
18
+ * Prevents LFI (Local File Inclusion) attacks by restricting
19
+ * local file extraction to known-safe directories.
20
+ *
21
+ * Backported from: openclaw/openclaw@2026.1.30 (PR #4880)
22
+ * Security: CRITICAL
23
+ */
24
+ const SAFE_PATHS = [
25
+ "/tmp",
26
+ "/var/tmp",
27
+ process.cwd(), // Current working directory
28
+ resolveConfigDir(), // PoolBot config directory
29
+ ];
30
+ /**
31
+ * Validate that a local path is safe for file extraction.
32
+ *
33
+ * Resolves the path and checks if it's within one of the SAFE_PATHS.
34
+ * Throws an error if the path is outside safe directories.
35
+ *
36
+ * @param filePath - The path to validate
37
+ * @throws Error if path is outside safe directories
38
+ */
39
+ function validateLocalPath(filePath) {
40
+ const resolved = path.resolve(filePath);
41
+ for (const safePath of SAFE_PATHS) {
42
+ if (resolved.startsWith(safePath)) {
43
+ return; // Path is safe
44
+ }
45
+ }
46
+ throw new Error(`Unsafe local path: ${filePath} (resolved: ${resolved}). ` +
47
+ `Path must be within one of: ${SAFE_PATHS.join(", ")}`);
48
+ }
15
49
  /**
16
50
  * Sanitize a filename for cross-platform safety.
17
51
  * Removes chars unsafe on Windows/SharePoint/all platforms.
@@ -158,6 +192,8 @@ export async function saveMediaSource(source, headers, subdir = "") {
158
192
  return { id, path: finalDest, size, contentType: mime };
159
193
  }
160
194
  // local path
195
+ // SECURITY: Validate local path before reading
196
+ validateLocalPath(source); // LFI prevention
161
197
  const stat = await fs.stat(source);
162
198
  if (!stat.isFile()) {
163
199
  throw new Error("Media path is not a file");
@@ -175,7 +211,7 @@ export async function saveMediaSource(source, headers, subdir = "") {
175
211
  }
176
212
  export async function saveMediaBuffer(buffer, contentType, subdir = "inbound", maxBytes = MAX_BYTES, originalFilename) {
177
213
  if (buffer.byteLength > maxBytes) {
178
- throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
214
+ throw new Error(`Media exceeds ${(maxBytes / 1024 / 1024).toFixed(0)}MB limit`);
179
215
  }
180
216
  const dir = path.join(resolveMediaDir(), subdir);
181
217
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
@@ -1 +1,6 @@
1
+ /**
2
+ * Memory Search - Public Exports
3
+ * Legacy system only (PME removed 2026-02-03)
4
+ */
5
+ // Legacy memory search system
1
6
  export { getMemorySearchManager } from "./search-manager.js";
@@ -157,6 +157,7 @@ export class MemoryIndexManager {
157
157
  this.sessionWarm.add(key);
158
158
  }
159
159
  async search(query, opts) {
160
+ const searchStart = Date.now();
160
161
  void this.warmSession(opts?.sessionKey);
161
162
  if (this.settings.sync.onSearch && (this.dirty || this.sessionsDirty)) {
162
163
  void this.sync({ reason: "search" }).catch((err) => {
@@ -179,7 +180,17 @@ export class MemoryIndexManager {
179
180
  ? await this.searchVector(queryVec, candidates).catch(() => [])
180
181
  : [];
181
182
  if (!hybrid.enabled) {
182
- return vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults);
183
+ const results = vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults);
184
+ const searchDuration = Date.now() - searchStart;
185
+ log.debug("memory search performance", {
186
+ query: cleaned.substring(0, 50),
187
+ durationMs: searchDuration,
188
+ resultCount: results.length,
189
+ provider: this.provider.id,
190
+ model: this.provider.model,
191
+ hybrid: false,
192
+ });
193
+ return results;
183
194
  }
184
195
  const merged = this.mergeHybridResults({
185
196
  vector: vectorResults,
@@ -187,7 +198,19 @@ export class MemoryIndexManager {
187
198
  vectorWeight: hybrid.vectorWeight,
188
199
  textWeight: hybrid.textWeight,
189
200
  });
190
- return merged.filter((entry) => entry.score >= minScore).slice(0, maxResults);
201
+ const results = merged.filter((entry) => entry.score >= minScore).slice(0, maxResults);
202
+ const searchDuration = Date.now() - searchStart;
203
+ log.debug("memory search performance", {
204
+ query: cleaned.substring(0, 50),
205
+ durationMs: searchDuration,
206
+ resultCount: results.length,
207
+ provider: this.provider.id,
208
+ model: this.provider.model,
209
+ hybrid: true,
210
+ keywordResults: keywordResults.length,
211
+ vectorResults: vectorResults.length,
212
+ });
213
+ return results;
191
214
  }
192
215
  async searchVector(queryVec, limit) {
193
216
  const results = await searchVector({