From 436656058fa535494cb3290dc163ca8f78f98791 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Wed, 28 Jan 2026 23:29:08 -0500 Subject: [PATCH] refactor(memory-claudemem): switch to hook CLI implementation - Replace HTTP client approach with direct hook CLI spawning - Auto-discover worker-service.cjs from Claude plugin cache - Sync MEMORY.md on session and gateway start - Handle custom project names via temp directories - Fire-and-forget observation recording for performance - Remove unused client.ts, config.ts, types.ts --- .../memory-claudemem/clawdbot.plugin.json | 27 - extensions/memory-claudemem/client.ts | 143 ----- extensions/memory-claudemem/config.ts | 49 -- extensions/memory-claudemem/index.ts | 536 ++++++++++++------ .../memory-claudemem/moltbot.plugin.json | 32 ++ extensions/memory-claudemem/types.ts | 24 - 6 files changed, 385 insertions(+), 426 deletions(-) delete mode 100644 extensions/memory-claudemem/clawdbot.plugin.json delete mode 100644 extensions/memory-claudemem/client.ts delete mode 100644 extensions/memory-claudemem/config.ts create mode 100644 extensions/memory-claudemem/moltbot.plugin.json delete mode 100644 extensions/memory-claudemem/types.ts diff --git a/extensions/memory-claudemem/clawdbot.plugin.json b/extensions/memory-claudemem/clawdbot.plugin.json deleted file mode 100644 index 5149fd97a..000000000 --- a/extensions/memory-claudemem/clawdbot.plugin.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "id": "memory-claudemem", - "name": "Memory (Claude-Mem)", - "description": "Real-time observation and memory via claude-mem worker", - "kind": "memory", - "version": "0.0.1", - "uiHints": { - "workerUrl": { - "label": "Worker URL", - "placeholder": "http://localhost:37777", - "help": "URL of the claude-mem worker" - }, - "workerTimeout": { - "label": "Timeout (ms)", - "placeholder": "10000", - "advanced": true - } - }, - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "workerUrl": { "type": "string", "default": "http://localhost:37777" }, - "workerTimeout": { "type": "number", "default": 10000 } - } - } -} diff --git a/extensions/memory-claudemem/client.ts b/extensions/memory-claudemem/client.ts deleted file mode 100644 index 806a969f6..000000000 --- a/extensions/memory-claudemem/client.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { SearchResult, Observation } from "./types.js"; - -export class ClaudeMemClient { - constructor( - private readonly baseUrl: string, - private readonly timeout: number, - ) {} - - /** - * Check if the claude-mem worker is healthy. - * Returns true if the worker responds to health check, false otherwise. - */ - async ping(): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/health`, { - method: "GET", - signal: AbortSignal.timeout(this.timeout), - }); - return response.ok; - } catch { - return false; - } - } - - /** - * Record an observation to the claude-mem worker. - * Fire-and-forget pattern: errors are logged but do not block. - */ - async observe( - claudeSessionId: string, - toolName: string, - toolInput: unknown, - toolResponse: unknown, - cwd?: string, - ): Promise { - try { - const body = { - claudeSessionId, - tool_name: toolName, - tool_input: toolInput, - tool_response: toolResponse, - ...(cwd && { cwd }), - }; - - await fetch(`${this.baseUrl}/api/sessions/observations`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(this.timeout), - }); - // Fire-and-forget: we don't check response status or throw on errors - } catch { - // Silently ignore errors - worker may be offline - // This is intentional: observations should never block the main flow - } - } - - /** - * Search observations by query. - * Returns an array of search results with id, title, snippet, and score. - */ - async search(query: string, limit = 10): Promise { - try { - const searchParams = new URLSearchParams({ - query, - type: "observations", - format: "index", - limit: String(limit), - }); - - const response = await fetch( - `${this.baseUrl}/api/search?${searchParams.toString()}`, - { - method: "GET", - signal: AbortSignal.timeout(this.timeout), - }, - ); - - if (!response.ok) { - return []; - } - - const data = await response.json(); - - // The API returns results in an array format - if (Array.isArray(data)) { - return data as SearchResult[]; - } - - // Handle wrapped response format { results: [...] } - if (data && Array.isArray(data.results)) { - return data.results as SearchResult[]; - } - - return []; - } catch { - return []; - } - } - - /** - * Fetch full observation details by IDs. - * Returns observations with narrative, files_modified, tool_name, tool_input, tool_response, and created_at_epoch. - */ - async getObservations(ids: number[]): Promise { - if (ids.length === 0) { - return []; - } - - try { - const response = await fetch(`${this.baseUrl}/api/observations/batch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ ids }), - signal: AbortSignal.timeout(this.timeout), - }); - - if (!response.ok) { - return []; - } - - const data = await response.json(); - - // Handle direct array response - if (Array.isArray(data)) { - return data as Observation[]; - } - - // Handle wrapped response format { observations: [...] } - if (data && Array.isArray(data.observations)) { - return data.observations as Observation[]; - } - - return []; - } catch { - return []; - } - } -} diff --git a/extensions/memory-claudemem/config.ts b/extensions/memory-claudemem/config.ts deleted file mode 100644 index b81167113..000000000 --- a/extensions/memory-claudemem/config.ts +++ /dev/null @@ -1,49 +0,0 @@ -export type ClaudeMemConfig = { - workerUrl: string; - workerTimeout: number; -}; - -const DEFAULT_WORKER_URL = "http://localhost:37777"; -const DEFAULT_TIMEOUT = 10000; - -function assertAllowedKeys( - value: Record, - allowed: string[], - label: string, -) { - const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); - if (unknown.length === 0) return; - throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); -} - -export const claudeMemConfigSchema = { - parse(value: unknown): ClaudeMemConfig { - if (!value || typeof value !== "object" || Array.isArray(value)) { - // Return defaults if no config provided - return { - workerUrl: DEFAULT_WORKER_URL, - workerTimeout: DEFAULT_TIMEOUT, - }; - } - const cfg = value as Record; - assertAllowedKeys(cfg, ["workerUrl", "workerTimeout"], "claude-mem config"); - - return { - workerUrl: typeof cfg.workerUrl === "string" ? cfg.workerUrl : DEFAULT_WORKER_URL, - workerTimeout: typeof cfg.workerTimeout === "number" ? cfg.workerTimeout : DEFAULT_TIMEOUT, - }; - }, - uiHints: { - workerUrl: { - label: "Worker URL", - placeholder: DEFAULT_WORKER_URL, - help: "URL of the claude-mem worker", - }, - workerTimeout: { - label: "Timeout (ms)", - placeholder: String(DEFAULT_TIMEOUT), - advanced: true, - help: "Request timeout in milliseconds", - }, - }, -}; diff --git a/extensions/memory-claudemem/index.ts b/extensions/memory-claudemem/index.ts index 6c7489752..8ea567f97 100644 --- a/extensions/memory-claudemem/index.ts +++ b/extensions/memory-claudemem/index.ts @@ -1,195 +1,365 @@ +/** + * claude-mem Moltbot Plugin + * + * Integrates claude-mem memory system using the official hook CLI. + * All operations go through the worker-service.cjs hook interface + * to ensure proper UI broadcasts and consistency with Claude Code. + */ + import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; -import { Type } from "@sinclair/typebox"; -import { claudeMemConfigSchema } from "./config.js"; -import { ClaudeMemClient } from "./client.js"; +import { writeFile, readdir, stat, mkdir } from "fs/promises"; +import { join, basename } from "path"; +import { homedir, tmpdir } from "os"; +import { spawn } from "child_process"; +import { existsSync } from "fs"; -const claudeMemPlugin = { - id: "memory-claudemem", - name: "Memory (Claude-Mem)", - description: "Real-time observation and memory via claude-mem worker", - kind: "memory" as const, - configSchema: claudeMemConfigSchema, +/** + * Find the latest claude-mem version in the plugin cache. + * Returns the path to worker-service.cjs or null if not found. + */ +async function findWorkerServicePath(): Promise { + const cacheDir = join(homedir(), ".claude/plugins/cache/thedotmack/claude-mem"); + + if (!existsSync(cacheDir)) { + return null; + } - register(api: ClawdbotPluginApi) { - const cfg = claudeMemConfigSchema.parse(api.pluginConfig); - const client = new ClaudeMemClient(cfg.workerUrl, cfg.workerTimeout); - - api.logger.info( - `memory-claudemem: plugin registered (worker: ${cfg.workerUrl})`, - ); - - // Hook: after_tool_call → Observe tool calls (fire-and-forget) - api.on("after_tool_call", async (event, ctx) => { - // Skip memory tools to prevent recursion - if (event.toolName.startsWith("memory_")) return; - - try { - // Fire-and-forget: don't await, let it run in parallel - // Use sessionKey as the session identifier (may be undefined) - client.observe( - ctx.sessionKey ?? "unknown", - event.toolName, - event.params, - event.result, - ); - } catch (err) { - api.logger.warn?.(`memory-claudemem: observation failed: ${err}`); + try { + const entries = await readdir(cacheDir); + + // Get version directories with their modification times + const versionDirs: { version: string; mtime: number }[] = []; + for (const entry of entries) { + const fullPath = join(cacheDir, entry); + const workerPath = join(fullPath, "scripts/worker-service.cjs"); + if (existsSync(workerPath)) { + const stats = await stat(fullPath); + versionDirs.push({ version: entry, mtime: stats.mtimeMs }); } - }); + } - // Hook: before_agent_start → Context injection from memory search - api.on("before_agent_start", async (event) => { - // Skip if prompt is empty or too short - if (!event.prompt || event.prompt.length < 5) return; + if (versionDirs.length === 0) { + return null; + } - try { - const results = await client.search(event.prompt, 5); - if (results.length === 0) return; + // Sort by modification time (newest first) + versionDirs.sort((a, b) => b.mtime - a.mtime); + + return join(cacheDir, versionDirs[0].version, "scripts/worker-service.cjs"); + } catch { + return null; + } +} - const memoryContext = results - .map((r) => `- [#${r.id}] ${r.title}: ${r.snippet}`) - .join("\n"); +// Will be initialized on plugin load +let WORKER_SERVICE_PATH: string | null = null; - api.logger.info?.( - `memory-claudemem: injecting ${results.length} memories into context`, - ); +/** + * Get the cwd to use for claude-mem hooks. + * Claude-mem derives project name from cwd basename, so if we have a custom + * project name, we need to use a directory with that name. + */ +async function getHookCwd(workspaceDir: string, project: string): Promise { + const workspaceName = basename(workspaceDir); + + // If project matches workspace name, use workspace directly + if (project === workspaceName) { + return workspaceDir; + } + + // Create a temp directory with the project name for claude-mem to use + const projectDir = join(tmpdir(), "claude-mem-projects", project); + if (!existsSync(projectDir)) { + await mkdir(projectDir, { recursive: true }); + } + return projectDir; +} - return { - prependContext: `\nThe following memories may be relevant:\n${memoryContext}\n`, - }; - } catch (err) { - api.logger.warn?.(`memory-claudemem: context injection failed: ${err}`); - } - }); +interface ClaudeMemConfig { + syncMemoryFile: boolean; + project: string; +} - // Phase 5: Tool registration - // memory_search - Layer 1: compact results (~50-100 tokens per result) - api.registerTool( - { - name: "memory_search", - label: "Memory Search", - description: - "Search past observations. Returns compact results with IDs. Use memory_observations for full details.", - parameters: Type.Object({ - query: Type.String({ description: "Search query" }), - limit: Type.Optional( - Type.Number({ description: "Max results (default: 10)" }), - ), - }), - async execute(_toolCallId, params) { - const { query, limit = 10 } = params as { - query: string; - limit?: number; - }; - - const results = await client.search(query, limit); - - if (results.length === 0) { - return { - content: [{ type: "text", text: "No relevant memories found." }], - }; - } - - const text = results.map((r) => `[#${r.id}] ${r.title}`).join("\n"); - - return { - content: [ - { - type: "text", - text: `Found ${results.length} memories:\n\n${text}`, - }, - ], - }; - }, - }, - { name: "memory_search" }, - ); - - // memory_observations - Layer 3: full details (~500-1000 tokens per result) - api.registerTool( - { - name: "memory_observations", - label: "Memory Observations", - description: - "Get full details for specific observation IDs. Use after memory_search to filter.", - parameters: Type.Object({ - ids: Type.Array(Type.Number(), { - description: "Array of observation IDs to fetch", - }), - }), - async execute(_toolCallId, params) { - const { ids } = params as { ids: number[] }; - - if (ids.length === 0) { - return { - content: [{ type: "text", text: "No observation IDs provided." }], - }; - } - - const observations = await client.getObservations(ids); - - if (observations.length === 0) { - return { - content: [ - { - type: "text", - text: "No observations found for the given IDs.", - }, - ], - }; - } - - const text = observations - .map((obs) => { - const filesSection = - obs.files_modified && obs.files_modified.length > 0 - ? `\n\nFiles: ${obs.files_modified.join(", ")}` - : ""; - return `## #${obs.id}\n${obs.narrative}${filesSection}`; - }) - .join("\n\n---\n\n"); - - return { - content: [{ type: "text", text }], - }; - }, - }, - { name: "memory_observations" }, - ); - - // Phase 6: CLI registration - api.registerCli( - ({ program }) => { - const claudeMem = program - .command("claude-mem") - .description("Claude-mem memory plugin commands"); - - claudeMem - .command("status") - .description("Check if the claude-mem worker is responding") - .action(async () => { - const isHealthy = await client.ping(); - if (isHealthy) { - console.log(`✓ Worker running at ${cfg.workerUrl}`); - } else { - console.log(`✗ Worker not responding at ${cfg.workerUrl}`); - process.exitCode = 1; - } - }); - - claudeMem - .command("search") - .description("Search memories") - .argument("", "Search query") - .option("--limit ", "Max results", "10") - .action(async (query, opts) => { - const results = await client.search(query, parseInt(opts.limit)); - console.log(JSON.stringify(results, null, 2)); - }); - }, - { commands: ["claude-mem"] }, - ); - }, +const DEFAULT_CONFIG: Omit = { + syncMemoryFile: true, }; -export default claudeMemPlugin; +/** + * Call the claude-mem hook CLI with JSON piped to stdin. + * Returns the parsed JSON output (or null on failure). + */ +function callHook( + hookName: string, + data: Record +): Promise | null> { + return new Promise((resolve) => { + if (!WORKER_SERVICE_PATH) { + console.error(`[claude-mem] hook ${hookName} failed: worker-service.cjs not found`); + resolve(null); + return; + } + try { + const proc = spawn("bun", [WORKER_SERVICE_PATH, "hook", "claude-code", hookName], { + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + + proc.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + proc.stdin.write(JSON.stringify(data)); + proc.stdin.end(); + + proc.on("close", (code) => { + if (code !== 0) { + console.error(`[claude-mem] hook ${hookName} failed (code ${code}): ${stderr}`); + resolve(null); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch { + resolve(null); + } + }); + + proc.on("error", (err) => { + console.error(`[claude-mem] hook ${hookName} error:`, err); + resolve(null); + }); + + // Timeout after 30s + setTimeout(() => { + proc.kill(); + resolve(null); + }, 30000); + } catch (err) { + console.error(`[claude-mem] hook ${hookName} spawn error:`, err); + resolve(null); + } + }); +} + +/** + * Fire-and-forget hook call (doesn't wait for output). + */ +function callHookFireAndForget(hookName: string, data: Record): void { + if (!WORKER_SERVICE_PATH) { + console.error(`[claude-mem] hook ${hookName} failed: worker-service.cjs not found`); + return; + } + try { + const proc = spawn("bun", [WORKER_SERVICE_PATH, "hook", "claude-code", hookName], { + stdio: ["pipe", "ignore", "ignore"], + detached: true, + }); + + proc.stdin.write(JSON.stringify(data)); + proc.stdin.end(); + proc.unref(); + } catch (err) { + console.error(`[claude-mem] hook ${hookName} fire-and-forget error:`, err); + } +} + +export default function (api: ClawdbotPluginApi) { + // Find the latest claude-mem worker service (sync check) + const cacheDir = join(homedir(), ".claude/plugins/cache/thedotmack/claude-mem"); + if (!existsSync(cacheDir)) { + api.logger.warn?.("claude-mem: plugin cache not found - plugin disabled"); + return; + } + + // Find latest version synchronously + try { + const entries = require("fs").readdirSync(cacheDir); + let latestVersion: string | null = null; + let latestMtime = 0; + + for (const entry of entries) { + const fullPath = join(cacheDir, entry); + const workerPath = join(fullPath, "scripts/worker-service.cjs"); + if (existsSync(workerPath)) { + const stats = require("fs").statSync(fullPath); + if (stats.mtimeMs > latestMtime) { + latestMtime = stats.mtimeMs; + latestVersion = entry; + } + } + } + + if (latestVersion) { + WORKER_SERVICE_PATH = join(cacheDir, latestVersion, "scripts/worker-service.cjs"); + } + } catch (err) { + api.logger.warn?.("claude-mem: failed to find worker service", err); + } + + if (!WORKER_SERVICE_PATH) { + api.logger.warn?.("claude-mem: worker-service.cjs not found - plugin disabled"); + return; + } + + api.logger.debug?.(`claude-mem: using worker at ${WORKER_SERVICE_PATH}`); + + const workspaceDir = api.workspaceDir || process.cwd(); + const defaultProject = basename(workspaceDir); + + const userConfig = api.pluginConfig as Partial; + const config: ClaudeMemConfig = { + ...DEFAULT_CONFIG, + project: defaultProject, + ...userConfig, + }; + + api.logger.info(`claude-mem: initializing (project: ${config.project})`); + + // Get the cwd to use for hooks (handles custom project names) + // Create temp dir synchronously if needed + let hookCwd = workspaceDir; + if (config.project !== defaultProject) { + const projectDir = join(tmpdir(), "claude-mem-projects", config.project); + if (!existsSync(projectDir)) { + require("fs").mkdirSync(projectDir, { recursive: true }); + } + hookCwd = projectDir; + } + api.logger.debug?.(`claude-mem: using hook cwd: ${hookCwd}`); + + // Track contentSessionId per moltbot session + const sessionIds = new Map(); + // Track whether we've synced MEMORY.md for each session + const syncedSessions = new Set(); + + function getContentSessionId(sessionKey: string | undefined): string { + const key = sessionKey || "default"; + if (!sessionIds.has(key)) { + sessionIds.set(key, `moltbot-${key}-${Date.now()}`); + } + return sessionIds.get(key)!; + } + + // before_agent_start → sync MEMORY.md (once per session) + record prompt + api.on("before_agent_start", async (event, ctx) => { + const sessionKey = ctx.sessionKey || "default"; + const contentSessionId = getContentSessionId(ctx.sessionKey); + + // Sync MEMORY.md once per session (on first agent start) + if (config.syncMemoryFile && !syncedSessions.has(sessionKey)) { + syncedSessions.add(sessionKey); + try { + const result = await callHook("context", { cwd: hookCwd }); + if (result) { + const context = + (result as any)?.hookSpecificOutput?.additionalContext || + (result as any)?.additionalContext; + + if (context && typeof context === "string") { + const memoryPath = join(workspaceDir, "MEMORY.md"); + await writeFile(memoryPath, context, "utf-8"); + api.logger.info?.(`claude-mem: updated MEMORY.md`); + } + } + } catch (error) { + api.logger.warn?.("claude-mem: failed to sync MEMORY.md", error); + } + } + + // Record prompt via session-init hook + if (!event.prompt || event.prompt.length < 10) return; + + const result = await callHook("session-init", { + session_id: contentSessionId, + prompt: event.prompt, + cwd: hookCwd, + }); + + if (result) { + api.logger.debug?.(`claude-mem: prompt recorded for session ${contentSessionId}`); + } + }); + + // tool_result_persist → hook claude-code observation (record tool call) + api.on("tool_result_persist", (event, ctx) => { + const toolName = event.toolName; + if (!toolName) return; + + // Skip memory tools to prevent recursion + const skipTools = new Set([ + "memory_search", + "memory_status", + "memory_record", + "mcp__plugin_claude-mem_mcp-search__search", + "mcp__plugin_claude-mem_mcp-search__timeline", + "mcp__plugin_claude-mem_mcp-search__get_observations", + ]); + if (skipTools.has(toolName)) return; + + const contentSessionId = getContentSessionId(ctx.sessionKey); + + // Extract tool result text + const message = event.message; + const content = message?.content; + let resultText: string | undefined; + + if (Array.isArray(content)) { + const textBlock = content.find( + (c: any) => c.type === "tool_result" || c.type === "text" + ); + if (textBlock && "text" in textBlock) { + resultText = String(textBlock.text).slice(0, 1000); + } + } + + // Fire-and-forget observation recording + callHookFireAndForget("observation", { + session_id: contentSessionId, + tool_name: toolName, + tool_input: event.params || {}, + tool_response: resultText || "", + cwd: hookCwd, + }); + }); + + // agent_end → hook claude-code summarize (generate session summary) + api.on("agent_end", async (_event, ctx) => { + const contentSessionId = getContentSessionId(ctx.sessionKey); + + // Fire-and-forget summary generation + callHookFireAndForget("summarize", { + session_id: contentSessionId, + cwd: hookCwd, + }); + }); + + // gateway_start → sync MEMORY.md when Moltbot starts + api.on("gateway_start", async () => { + if (!config.syncMemoryFile) return; + + try { + const result = await callHook("context", { cwd: hookCwd }); + if (result) { + const context = + (result as any)?.hookSpecificOutput?.additionalContext || + (result as any)?.additionalContext; + + if (context && typeof context === "string") { + const memoryPath = join(workspaceDir, "MEMORY.md"); + await writeFile(memoryPath, context, "utf-8"); + api.logger.info?.(`claude-mem: synced MEMORY.md on gateway start`); + } + } + } catch (error) { + api.logger.warn?.("claude-mem: failed to sync MEMORY.md on gateway start", error); + } + }); + + api.logger.info("claude-mem: plugin registered (using hook CLI)"); +} diff --git a/extensions/memory-claudemem/moltbot.plugin.json b/extensions/memory-claudemem/moltbot.plugin.json new file mode 100644 index 000000000..916741cab --- /dev/null +++ b/extensions/memory-claudemem/moltbot.plugin.json @@ -0,0 +1,32 @@ +{ + "id": "memory-claudemem", + "name": "Memory (Claude-Mem)", + "description": "Integrates claude-mem memory system using the hook CLI for persistent context across sessions", + "kind": "memory", + "version": "0.1.0", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "project": { + "type": "string", + "description": "Project name for scoping observations (defaults to workspace directory name)" + }, + "syncMemoryFile": { + "type": "boolean", + "default": true, + "description": "Sync MEMORY.md with claude-mem context on session start" + } + } + }, + "uiHints": { + "project": { + "label": "Project Name", + "help": "Scopes observations to this project. Defaults to workspace directory name." + }, + "syncMemoryFile": { + "label": "Sync MEMORY.md", + "help": "Update MEMORY.md with claude-mem context on session/gateway start" + } + } +} diff --git a/extensions/memory-claudemem/types.ts b/extensions/memory-claudemem/types.ts deleted file mode 100644 index 0a0833ae7..000000000 --- a/extensions/memory-claudemem/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** Search result from claude-mem worker (index format) */ -export type SearchResult = { - id: number; - title: string; - snippet?: string; - score?: number; -}; - -/** Full observation from claude-mem worker */ -export type Observation = { - id: number; - narrative: string; - files_modified?: string[]; - tool_name?: string; - tool_input?: unknown; - tool_response?: unknown; - created_at_epoch?: number; -}; - -/** Plugin configuration */ -export type ClaudeMemConfig = { - workerUrl: string; - workerTimeout: number; -};