From d441ab814d1a5ca3b5291dddd3c986980f1be7dc Mon Sep 17 00:00:00 2001 From: akhater Date: Mon, 30 Mar 2026 11:38:02 +0300 Subject: [PATCH] feat(openclaw): configurable logging with structured output (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(openclaw): configurable logging with structured output Replace raw console.log/warn/error spam with a structured logger. New plugin settings: logLevel, logSummaryIntervalMs, logCompact. Bank mission log demoted to verbose-only. Retain/recall batched into periodic summaries. Each recall now shows memory count injected. Co-Authored-By: Claude Opus 4.6 (1M context) * use api.logger for framework-consistent output, show autoRecall/autoRetain on init Route all log output through OpenClaw's api.logger instead of raw console calls. Matches mem0 plugin style. Startup now shows mode + feature flags. Dropped logCompact setting (framework handles formatting). Added subtle slate-blue color to hindsight prefix for visual differentiation. Co-Authored-By: Claude Opus 4.6 (1M context) * add bank name to init and summary logs, fix singular/plural consistency Co-Authored-By: Claude Opus 4.6 (1M context) * rename log levels to standard: off, error, warning, info, debug Per review feedback — use standard level names instead of custom ones. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: billy Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Nicolò Boschi --- .../openclaw/openclaw.plugin.json | 24 ++- hindsight-integrations/openclaw/src/client.ts | 15 +- hindsight-integrations/openclaw/src/index.ts | 62 +++++--- hindsight-integrations/openclaw/src/logger.ts | 147 ++++++++++++++++++ hindsight-integrations/openclaw/src/types.ts | 8 + 5 files changed, 228 insertions(+), 28 deletions(-) create mode 100644 hindsight-integrations/openclaw/src/logger.ts diff --git a/hindsight-integrations/openclaw/openclaw.plugin.json b/hindsight-integrations/openclaw/openclaw.plugin.json index bd010890..f70fad0f 100644 --- a/hindsight-integrations/openclaw/openclaw.plugin.json +++ b/hindsight-integrations/openclaw/openclaw.plugin.json @@ -202,9 +202,20 @@ }, "debug": { "type": "boolean", - "description": "Enable debug logging for Hindsight plugin operations.", + "description": "Enable debug logging for Hindsight plugin operations. Equivalent to logLevel: 'debug'.", "default": false - } + }, + "logLevel": { + "type": "string", + "description": "Console log verbosity. 'off' = no output, 'error' = errors only, 'warning' = errors + warnings, 'info' = key events + periodic summaries, 'debug' = all details.", + "enum": ["off", "error", "warning", "info", "debug"], + "default": "info" + }, + "logSummaryIntervalMs": { + "type": "number", + "description": "Interval in ms to batch retain/recall log summaries. 0 = log every event individually. Default: 300000 (5 min).", + "default": 300000 + }, }, "additionalProperties": false }, @@ -331,6 +342,13 @@ }, "debug": { "label": "Debug" - } + }, + "logLevel": { + "label": "Log Level" + }, + "logSummaryIntervalMs": { + "label": "Log Summary Interval (ms)", + "placeholder": "300000" + }, } } diff --git a/hindsight-integrations/openclaw/src/client.ts b/hindsight-integrations/openclaw/src/client.ts index 25de3f7e..3da0bb21 100644 --- a/hindsight-integrations/openclaw/src/client.ts +++ b/hindsight-integrations/openclaw/src/client.ts @@ -10,6 +10,7 @@ import type { RecallRequest, RecallResponse, } from './types.js'; +import * as log from './logger.js'; const execFileAsync = promisify(execFile); @@ -106,9 +107,9 @@ export class HindsightClient { const body = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status}: ${body}`); } - console.log(`[Hindsight] Bank mission set via HTTP`); + log.verbose('bank mission set via HTTP'); } catch (error) { - console.warn(`[Hindsight] Could not set bank mission (bank may not exist yet): ${error}`); + log.warn(`could not set bank mission (bank may not exist yet): ${error}`); } } @@ -117,10 +118,10 @@ export class HindsightClient { const args = [...baseArgs, '--profile', 'openclaw', 'bank', 'mission', this.bankId, sanitize(mission)]; try { const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER }); - console.log(`[Hindsight] Bank mission set: ${stdout.trim()}`); + log.verbose(`bank mission set: ${stdout.trim()}`); } catch (error) { // Don't fail if mission set fails - bank might not exist yet, will be created on first retain - console.warn(`[Hindsight] Could not set bank mission (bank may not exist yet): ${error}`); + log.warn(`could not set bank mission (bank may not exist yet): ${error}`); } } @@ -157,7 +158,7 @@ export class HindsightClient { } const data = await res.json(); - console.log(`[Hindsight] Retained via HTTP (async): ${JSON.stringify(data).substring(0, 200)}`); + log.verbose(`retained via HTTP (async): ${JSON.stringify(data).substring(0, 200)}`); return { message: 'Memory queued for background processing', @@ -183,7 +184,7 @@ export class HindsightClient { const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'retain-files', this.bankId, tempFile, '--async']; const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER }); - console.log(`[Hindsight] Retained (async): ${stdout.trim()}`); + log.verbose(`retained (async): ${stdout.trim()}`); return { message: 'Memory queued for background processing', @@ -211,7 +212,7 @@ export class HindsightClient { // Defense-in-depth: truncate query to stay under API's 500-token limit const MAX_QUERY_CHARS = 800; const query = request.query.length > MAX_QUERY_CHARS - ? (console.warn(`[Hindsight] Truncating recall query from ${request.query.length} to ${MAX_QUERY_CHARS} chars`), + ? (log.warn(`truncating recall query from ${request.query.length} to ${MAX_QUERY_CHARS} chars`), request.query.substring(0, MAX_QUERY_CHARS)) : request.query; const body: Record = { diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index b16bf7b1..abddaa3f 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -4,11 +4,13 @@ import { HindsightClient, type HindsightClientOptions } from './client.js'; import { createHash } from 'crypto'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; +import * as log from './logger.js'; +import { configureLogger, setApiLogger, stopLogger } from './logger.js'; -// Debug logging: silent by default, enable with debug: true in plugin config +// Debug logging: silent by default, enable with debug: true or logLevel: 'debug' let debugEnabled = false; const debug = (...args: unknown[]) => { - if (debugEnabled) console.log(...args); + if (debugEnabled) log.verbose(args.map(a => typeof a === 'string' ? a.replace(/^\[Hindsight\]\s*/, '') : String(a)).join(' ')); }; // Module-level state @@ -113,7 +115,7 @@ async function lazyReinit(): Promise { initPromise = Promise.resolve(); debug('[Hindsight] ✓ Lazy re-initialization succeeded'); } catch (error) { - console.warn(`[Hindsight] Lazy re-initialization failed (will retry in ${REINIT_COOLDOWN_MS / 1000}s):`, error instanceof Error ? error.message : error); + log.warn(`lazy re-init failed (retry in ${REINIT_COOLDOWN_MS / 1000}s): ${error instanceof Error ? error.message : error}`); } finally { isReinitInProgress = false; } @@ -175,7 +177,7 @@ if (typeof global !== 'undefined') { debug(`[Hindsight] Set mission for new bank: ${bankId}`); } catch (error) { // Log but don't fail - bank mission is not critical - console.warn(`[Hindsight] Could not set bank mission for ${bankId}: ${error}`); + log.warn(`could not set bank mission for ${bankId}: ${error}`); } } @@ -453,7 +455,7 @@ export function deriveBankId(ctx: PluginHookAgentContext | undefined, pluginConf const validFields = new Set(['agent', 'channel', 'user', 'provider']); for (const f of fields) { if (!validFields.has(f)) { - console.warn(`[Hindsight] Unknown dynamicBankGranularity field "${f}" — will resolve to "unknown" in bank ID. Valid fields: agent, channel, user, provider`); + log.warn(`unknown dynamicBankGranularity field "${f}" — will resolve to "unknown". Valid: agent, channel, user, provider`); } } @@ -738,7 +740,15 @@ export default function (api: MoltbotPluginAPI) { // Get plugin config first (needed for LLM detection and debug flag) const pluginConfig = getPluginConfig(api); - debugEnabled = pluginConfig.debug ?? false; + // If logLevel is 'debug', also enable legacy debug flag + debugEnabled = pluginConfig.debug ?? (pluginConfig.logLevel === 'debug'); + + // Configure structured logger — route through OpenClaw's api.logger for consistent formatting + if (api.logger) setApiLogger(api.logger); + configureLogger({ + logLevel: pluginConfig.logLevel ?? (pluginConfig.debug ? 'debug' : 'info'), + logSummaryIntervalMs: pluginConfig.logSummaryIntervalMs, + }); // Store config globally for bank ID derivation in hooks currentPluginConfig = pluginConfig; @@ -817,6 +827,12 @@ export default function (api: MoltbotPluginAPI) { await client.setBankMission(pluginConfig.bankMission); } + if (!isInitialized) { + const mode = 'external API'; + const autoRecall = pluginConfig.autoRecall !== false; + const autoRetain = pluginConfig.autoRetain !== false; + log.info(`initialized (mode: ${mode}, bank: ${defaultBankId}, autoRecall: ${autoRecall}, autoRetain: ${autoRetain})`); + } isInitialized = true; debug('[Hindsight] ✓ Ready (external API mode)'); } else { @@ -856,11 +872,17 @@ export default function (api: MoltbotPluginAPI) { await client.setBankMission(pluginConfig.bankMission); } + if (!isInitialized) { + const mode = 'local daemon'; + const autoRecall = pluginConfig.autoRecall !== false; + const autoRetain = pluginConfig.autoRetain !== false; + log.info(`initialized (mode: ${mode}, bank: ${defaultBankId}, autoRecall: ${autoRecall}, autoRetain: ${autoRetain})`); + } isInitialized = true; debug('[Hindsight] ✓ Ready'); } } catch (error) { - console.error('[Hindsight] Initialization error:', error); + log.error('initialization error', error); throw error; } })(); @@ -880,7 +902,7 @@ export default function (api: MoltbotPluginAPI) { try { await initPromise; } catch (error) { - console.error('[Hindsight] Initial initialization failed:', error); + log.error('initial initialization failed', error); // Continue to health check below } } @@ -894,7 +916,7 @@ export default function (api: MoltbotPluginAPI) { debug('[Hindsight] External API is healthy'); return; } catch (error) { - console.error('[Hindsight] External API health check failed:', error); + log.error('external API health check failed', error); // Reset state for reinitialization attempt client = null; clientOptions = null; @@ -1003,9 +1025,10 @@ export default function (api: MoltbotPluginAPI) { banksWithMissionSet.clear(); isInitialized = false; + stopLogger(); debug('[Hindsight] Service stopped'); } catch (error) { - console.error('[Hindsight] Service stop error:', error); + log.error('service stop error', error); throw error; } }, @@ -1143,6 +1166,8 @@ ${memoriesFormatted} `; debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bankId}`); + log.info(`injecting ${results.length} memories into context (bank: ${bankId})`); + log.trackRecall(bankId, results.length); // Inject recalled memories. Position is configurable to preserve prompt caching // when agents have large static system prompts. @@ -1158,11 +1183,11 @@ ${memoriesFormatted} } } catch (error) { if (error instanceof DOMException && error.name === 'TimeoutError') { - console.warn(`[Hindsight] Auto-recall timed out after ${pluginConfig.recallTimeoutMs ?? DEFAULT_RECALL_TIMEOUT_MS}ms, skipping memory injection`); + log.warn(`[Hindsight] Auto-recall timed out after ${pluginConfig.recallTimeoutMs ?? DEFAULT_RECALL_TIMEOUT_MS}ms, skipping memory injection`); } else if (error instanceof Error && error.name === 'AbortError') { - console.warn(`[Hindsight] Auto-recall aborted after ${pluginConfig.recallTimeoutMs ?? DEFAULT_RECALL_TIMEOUT_MS}ms, skipping memory injection`); + log.warn(`[Hindsight] Auto-recall aborted after ${pluginConfig.recallTimeoutMs ?? DEFAULT_RECALL_TIMEOUT_MS}ms, skipping memory injection`); } else { - console.error('[Hindsight] Auto-recall error:', error); + log.error('auto-recall error', error); } return; } @@ -1250,7 +1275,7 @@ ${memoriesFormatted} // Wait for client to be ready const clientGlobal = (global as any).__hindsightClient; if (!clientGlobal) { - console.warn('[Hindsight] Client global not found, skipping retain'); + log.warn('client global not found, skipping retain'); return; } @@ -1259,7 +1284,7 @@ ${memoriesFormatted} // Get client configured for this context's bank (async to handle mission setup) const client = await clientGlobal.getClientForContext(effectiveCtxForRetain); if (!client) { - console.warn('[Hindsight] Client not initialized, skipping retain'); + log.warn('client not initialized, skipping retain'); return; } @@ -1282,16 +1307,17 @@ ${memoriesFormatted} }, }); + log.trackRetain(bankId, messageCount); debug(`[Hindsight] Retained ${messageCount} messages to bank ${bankId} for session ${documentId}`); } catch (error) { - console.error('[Hindsight] Error retaining messages:', error); + log.error('error retaining messages', error); } }); debug('[Hindsight] Hooks registered'); } catch (error) { - console.error('[Hindsight] Plugin loading error:', error); + log.error('plugin loading error', error); if (error instanceof Error) { - console.error('[Hindsight] Error stack:', error.stack); + log.error('error stack', error.stack); } throw error; } diff --git a/hindsight-integrations/openclaw/src/logger.ts b/hindsight-integrations/openclaw/src/logger.ts new file mode 100644 index 00000000..6bf85ff7 --- /dev/null +++ b/hindsight-integrations/openclaw/src/logger.ts @@ -0,0 +1,147 @@ +/** + * Hindsight OpenClaw plugin logger. + * + * Routes output through OpenClaw's api.logger for consistent formatting + * with other plugins (same colors/timestamps as mem0, etc.). + * + * Features: + * - Configurable log level: 'off' | 'error' | 'warning' | 'info' | 'debug' + * - Batched retain/recall summaries instead of per-event spam + */ + +export type LogLevel = 'off' | 'error' | 'warning' | 'info' | 'debug'; + +export interface LoggerConfig { + /** Minimum severity to print. Default: 'info' */ + logLevel?: LogLevel; + /** Interval in ms to print batched retain/recall summaries. 0 = print every event. Default: 300000 (5 min) */ + logSummaryIntervalMs?: number; +} + +// Muted blue (38;5;103 = slate/dusty blue from 256-color palette) +const PREFIX = '\x1b[38;5;103mhindsight:\x1b[0m'; + +const LEVEL_RANK: Record = { + off: 0, + error: 1, + warning: 2, + info: 3, + debug: 4, +}; + +// Output backend — set via setApiLogger, falls back to console +let apiLogger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void } = { + info: (msg) => console.log(msg), + warn: (msg) => console.warn(msg), + error: (msg) => console.error(msg), +}; + +// Batched summary state +let retainCount = 0; +let retainMsgTotal = 0; +let recallCount = 0; +let recallMemoriesCount = 0; +const banksSeen = new Set(); +let lastSummaryTime = Date.now(); +let summaryTimer: ReturnType | null = null; + +let currentLevel: LogLevel = 'info'; +let currentSummaryIntervalMs = 300_000; // 5 min + +/** Bind to OpenClaw's api.logger for consistent output formatting */ +export function setApiLogger(logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void }): void { + apiLogger = logger; +} + +export function configureLogger(cfg: LoggerConfig): void { + currentLevel = cfg.logLevel ?? 'info'; + currentSummaryIntervalMs = cfg.logSummaryIntervalMs ?? 300_000; + + // Restart summary timer + if (summaryTimer) { + clearInterval(summaryTimer); + summaryTimer = null; + } + if (currentSummaryIntervalMs > 0 && LEVEL_RANK[currentLevel] >= LEVEL_RANK['info']) { + summaryTimer = setInterval(flushSummary, currentSummaryIntervalMs); + summaryTimer.unref?.(); // don't keep process alive + } +} + +function allowed(level: LogLevel): boolean { + return LEVEL_RANK[currentLevel] >= LEVEL_RANK[level]; +} + +/** Info-level log (requires 'info' or higher) */ +export function info(msg: string): void { + if (!allowed('info')) return; + apiLogger.info(`${PREFIX} ${msg}`); +} + +/** Debug log (requires 'debug') */ +export function verbose(msg: string): void { + if (!allowed('debug')) return; + apiLogger.info(`${PREFIX} ${msg}`); +} + +/** Warning (requires 'warning' or higher) */ +export function warn(msg: string): void { + if (!allowed('warning')) return; + apiLogger.warn(`${PREFIX} ${msg}`); +} + +/** Error (requires 'error' or higher) */ +export function error(msg: string, err?: unknown): void { + if (!allowed('error')) return; + const detail = err instanceof Error ? err.message : (err ? String(err) : ''); + apiLogger.error(`${PREFIX} ${detail ? `${msg}: ${detail}` : msg}`); +} + +/** Track a retain event for batched summary */ +export function trackRetain(bankId: string, messageCount: number): void { + retainCount++; + retainMsgTotal += messageCount; + banksSeen.add(bankId); + if (currentSummaryIntervalMs === 0 && allowed('info')) { + apiLogger.info(`${PREFIX} auto-retained ${messageCount} messages (bank: ${bankId})`); + } +} + +/** Track a recall event for batched summary */ +export function trackRecall(bankId: string, memoriesFound: number): void { + recallCount++; + recallMemoriesCount += memoriesFound; + banksSeen.add(bankId); + // per-event logging is handled by info() call at the injection site +} + +/** Flush the batched summary to console */ +export function flushSummary(): void { + if (!allowed('info')) return; + if (retainCount === 0 && recallCount === 0) return; + + const elapsed = Math.round((Date.now() - lastSummaryTime) / 1000); + const parts: string[] = []; + if (recallCount > 0) parts.push(`${recallCount} recalls (${recallMemoriesCount} memories injected)`); + if (retainCount > 0) parts.push(`${retainCount} retains (${retainMsgTotal} messages captured)`); + const bankList = [...banksSeen]; + const bankLabel = bankList.length === 1 ? 'bank' : 'banks'; + const banks = bankList.length > 0 ? ` (${bankLabel}: ${bankList.join(', ')})` : ''; + apiLogger.info(`${PREFIX} ${parts.join(', ')} in ${elapsed}s${banks}`); + + retainCount = 0; + retainMsgTotal = 0; + recallCount = 0; + recallMemoriesCount = 0; + banksSeen.clear(); + lastSummaryTime = Date.now(); +} + +/** Cleanup (call on plugin stop) */ +export function stopLogger(): void { + flushSummary(); + if (summaryTimer) { + clearInterval(summaryTimer); + summaryTimer = null; + } +} diff --git a/hindsight-integrations/openclaw/src/types.ts b/hindsight-integrations/openclaw/src/types.ts index 22eea630..48a6af19 100644 --- a/hindsight-integrations/openclaw/src/types.ts +++ b/hindsight-integrations/openclaw/src/types.ts @@ -11,6 +11,12 @@ export interface MoltbotPluginAPI { registerService(config: ServiceConfig): void; // OpenClaw hook handler signature: (event, ctx?) where ctx contains channel/sender info on(event: string, handler: (event: any, ctx?: any) => void | Promise): void; + // OpenClaw framework logger — handles coloring/formatting consistently across plugins + logger: { + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; + }; // Add more as needed } @@ -75,6 +81,8 @@ export interface PluginConfig { recallPromptPreamble?: string; // Prompt preamble placed above recalled memories. Default: built-in guidance text. recallInjectionPosition?: 'prepend' | 'append' | 'user'; // Where to inject recalled memories. 'prepend' = start of system prompt (default), 'append' = end of system prompt (preserves prompt cache), 'user' = before user message. debug?: boolean; // Enable debug logging (default: false) + logLevel?: 'off' | 'error' | 'warning' | 'info' | 'debug'; // Console log verbosity (default: 'info'). + logSummaryIntervalMs?: number; // Batch retain/recall log summaries over this interval in ms. 0 = log every event. Default: 300000 (5 min). } export interface ServiceConfig {