feat(openclaw): configurable logging with structured output (#739)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* add bank name to init and summary logs, fix singular/plural consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: billy <billy@oclaw.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
This commit is contained in:
akhater 2026-03-30 11:38:02 +03:00 committed by GitHub
parent f8285b7b90
commit d441ab814d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 228 additions and 28 deletions

View file

@ -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"
},
}
}

View file

@ -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<string, unknown> = {

View file

@ -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<void> {
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}
</hindsight_memories>`;
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;
}

View file

@ -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<LogLevel, number> = {
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<string>();
let lastSummaryTime = Date.now();
let summaryTimer: ReturnType<typeof setInterval> | 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;
}
}

View file

@ -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 | PluginPromptHookResult>): 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 {