fleet-memory/hindsight-integrations/openclaw/src/client.ts
akhater d441ab814d
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>
2026-03-30 10:38:02 +02:00

261 lines
8.7 KiB
TypeScript

import { execFile } from 'child_process';
import { promisify } from 'util';
import { writeFile, mkdir, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomBytes } from 'crypto';
import type {
RetainRequest,
RetainResponse,
RecallRequest,
RecallResponse,
} from './types.js';
import * as log from './logger.js';
const execFileAsync = promisify(execFile);
const MAX_BUFFER = 5 * 1024 * 1024; // 5 MB — large transcripts can exceed default 1 MB
const DEFAULT_TIMEOUT_MS = 15_000;
/** Strip null bytes from strings — Node 22 rejects them in execFile() args */
const sanitize = (s: string) => s.replace(/\0/g, '');
/**
* Sanitize a string for use as a cross-platform filename.
* Replaces characters illegal on Windows or Unix with underscores.
*/
function sanitizeFilename(name: string): string {
// Replace characters illegal on Windows (\/:*?"<>|) and control chars
return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 200) || 'content';
}
export interface HindsightClientOptions {
llmModel?: string;
embedVersion?: string;
embedPackagePath?: string;
apiUrl?: string; // Direct HTTP mode — bypass subprocess
apiToken?: string; // Auth header for HTTP mode
}
export class HindsightClient {
private bankId: string = 'default';
private llmModel?: string;
private embedVersion: string;
private embedPackagePath?: string;
private apiUrl?: string;
private apiToken?: string;
constructor(opts: HindsightClientOptions) {
this.llmModel = opts.llmModel;
this.embedVersion = opts.embedVersion || 'latest';
this.embedPackagePath = opts.embedPackagePath;
this.apiUrl = opts.apiUrl?.replace(/\/$/, ''); // strip trailing slash
this.apiToken = opts.apiToken;
}
private get httpMode(): boolean {
return !!this.apiUrl;
}
/**
* Get the command and base args to run hindsight-embed.
* Returns [command, ...baseArgs] for use with execFile/spawn (no shell).
*/
private getEmbedCommand(): string[] {
if (this.embedPackagePath) {
return ['uv', 'run', '--directory', this.embedPackagePath, 'hindsight-embed'];
}
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
return ['uvx', embedPackage];
}
private httpHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.apiToken) {
headers['Authorization'] = `Bearer ${this.apiToken}`;
}
return headers;
}
setBankId(bankId: string): void {
this.bankId = bankId;
}
// --- setBankMission ---
async setBankMission(mission: string): Promise<void> {
if (!mission || mission.trim().length === 0) {
return;
}
if (this.httpMode) {
return this.setBankMissionHttp(mission);
}
return this.setBankMissionSubprocess(mission);
}
private async setBankMissionHttp(mission: string): Promise<void> {
try {
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}`;
const res = await fetch(url, {
method: 'PUT',
headers: this.httpHeaders(),
body: JSON.stringify({ mission }),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${body}`);
}
log.verbose('bank mission set via HTTP');
} catch (error) {
log.warn(`could not set bank mission (bank may not exist yet): ${error}`);
}
}
private async setBankMissionSubprocess(mission: string): Promise<void> {
const [cmd, ...baseArgs] = this.getEmbedCommand();
const args = [...baseArgs, '--profile', 'openclaw', 'bank', 'mission', this.bankId, sanitize(mission)];
try {
const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER });
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
log.warn(`could not set bank mission (bank may not exist yet): ${error}`);
}
}
// --- retain ---
async retain(request: RetainRequest): Promise<RetainResponse> {
if (this.httpMode) {
return this.retainHttp(request);
}
return this.retainSubprocess(request);
}
private async retainHttp(request: RetainRequest): Promise<RetainResponse> {
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories`;
const body = {
items: [{
content: request.content,
document_id: request.document_id || 'conversation',
metadata: request.metadata,
}],
async: true,
};
const res = await fetch(url, {
method: 'POST',
headers: this.httpHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to retain memory (HTTP ${res.status}): ${text}`);
}
const data = await res.json();
log.verbose(`retained via HTTP (async): ${JSON.stringify(data).substring(0, 200)}`);
return {
message: 'Memory queued for background processing',
document_id: request.document_id || 'conversation',
memory_unit_ids: [],
};
}
private async retainSubprocess(request: RetainRequest): Promise<RetainResponse> {
const docId = request.document_id || 'conversation';
// Write content to a temp file to avoid E2BIG (ARG_MAX) errors when passing
// large conversations as arguments.
const tempDir = join(tmpdir(), `hindsight_${randomBytes(8).toString('hex')}`);
const safeFilename = sanitizeFilename(docId);
const tempFile = join(tempDir, `${safeFilename}.txt`);
try {
await mkdir(tempDir, { recursive: true });
await writeFile(tempFile, sanitize(request.content), 'utf8');
const [cmd, ...baseArgs] = this.getEmbedCommand();
const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'retain-files', this.bankId, tempFile, '--async'];
const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER });
log.verbose(`retained (async): ${stdout.trim()}`);
return {
message: 'Memory queued for background processing',
document_id: docId,
memory_unit_ids: [],
};
} catch (error) {
throw new Error(`Failed to retain memory: ${error}`, { cause: error });
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
// --- recall ---
async recall(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
if (this.httpMode) {
return this.recallHttp(request, timeoutMs);
}
return this.recallSubprocess(request, timeoutMs);
}
private async recallHttp(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories/recall`;
// 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
? (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> = {
query,
max_tokens: request.max_tokens || 1024,
};
if (request.budget) {
body.budget = request.budget;
}
if (request.types) {
body.types = request.types;
}
const res = await fetch(url, {
method: 'POST',
headers: this.httpHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`);
}
return res.json() as Promise<RecallResponse>;
}
private async recallSubprocess(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
const query = sanitize(request.query);
const maxTokens = request.max_tokens || 1024;
const [cmd, ...baseArgs] = this.getEmbedCommand();
const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'recall', this.bankId, query, '--output', 'json', '--max-tokens', String(maxTokens)];
try {
const { stdout } = await execFileAsync(cmd, args, {
maxBuffer: MAX_BUFFER,
timeout: timeoutMs ?? 30_000, // subprocess gets a longer default
});
return JSON.parse(stdout) as RecallResponse;
} catch (error) {
throw new Error(`Failed to recall memories: ${error}`, { cause: error });
}
}
}