@aman_asmuei/aman-agent 0.40.0 → 0.42.0

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/logger.ts","../src/server/registry.ts","../src/delegate-remote.ts","../src/delegate.ts","../src/prompt.ts","../src/token-budget.ts","../src/user-identity.ts","../src/config.ts","../src/retry.ts","../src/hooks.ts","../src/personality.ts","../src/memory.ts","../src/postmortem.ts","../src/observation.ts","../src/crystallization.ts","../src/user-model.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\n\nconst LOG_DIR = path.join(os.homedir(), \".aman-agent\");\nexport const LOG_PATH = path.join(LOG_DIR, \"debug.log\");\nconst MAX_LOG_SIZE = 1_048_576; // 1MB\n\ninterface LogEntry {\n timestamp: string;\n level: \"debug\" | \"warn\" | \"error\";\n module: string;\n message: string;\n data?: string;\n}\n\nfunction ensureDir(): void {\n if (!fs.existsSync(LOG_DIR)) {\n fs.mkdirSync(LOG_DIR, { recursive: true });\n }\n}\n\nfunction maybeRotate(): void {\n try {\n if (!fs.existsSync(LOG_PATH)) return;\n const stat = fs.statSync(LOG_PATH);\n if (stat.size >= MAX_LOG_SIZE) {\n const backupPath = LOG_PATH + \".1\";\n if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath);\n fs.renameSync(LOG_PATH, backupPath);\n }\n } catch {\n // Rotation failure is non-critical\n }\n}\n\nfunction write(level: LogEntry[\"level\"], module: string, message: string, data?: unknown): void {\n try {\n ensureDir();\n maybeRotate();\n const entry: LogEntry = {\n timestamp: new Date().toISOString(),\n level,\n module,\n message,\n };\n if (data !== undefined) {\n entry.data = data instanceof Error ? data.message : String(data);\n }\n fs.appendFileSync(LOG_PATH, JSON.stringify(entry) + \"\\n\");\n } catch {\n // Logger must never throw\n }\n}\n\nexport const log = {\n debug: (module: string, message: string, data?: unknown) => write(\"debug\", module, message, data),\n warn: (module: string, message: string, data?: unknown) => write(\"warn\", module, message, data),\n error: (module: string, message: string, data?: unknown) => write(\"error\", module, message, data),\n};\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { log } from \"../logger.js\";\n\nexport interface AgentEntry {\n name: string; // unique handle, used as @name\n profile: string; // aman-agent profile this server loaded\n pid: number; // process id for liveness check\n port: number; // 127.0.0.1 port\n token: string; // 32-byte hex bearer\n started_at: number; // epoch ms\n version: string; // package version\n}\n\nexport interface ListOptions {\n prune?: boolean; // write the pruned registry back\n isAlive?: (pid: number) => boolean; // injectable for tests\n}\n\nfunction amanAgentHome(): string {\n return process.env.AMAN_AGENT_HOME || path.join(os.homedir(), \".aman-agent\");\n}\n\nfunction registryPath(): string {\n return path.join(amanAgentHome(), \"registry.json\");\n}\n\nasync function ensureHome(): Promise<void> {\n await fs.mkdir(amanAgentHome(), { recursive: true });\n}\n\nasync function readRaw(): Promise<AgentEntry[]> {\n try {\n const buf = await fs.readFile(registryPath(), \"utf-8\");\n const parsed = JSON.parse(buf);\n return Array.isArray(parsed) ? parsed : [];\n } catch (err: unknown) {\n const code = (err as { code?: string }).code;\n if (code === \"ENOENT\") return [];\n const message = err instanceof Error ? err.message : String(err);\n log.warn(\"registry\", `failed to read registry: ${message}`);\n return [];\n }\n}\n\nasync function writeAtomic(entries: AgentEntry[]): Promise<void> {\n await ensureHome();\n const tmp = registryPath() + \".tmp\";\n await fs.writeFile(tmp, JSON.stringify(entries, null, 2), { mode: 0o600 });\n await fs.rename(tmp, registryPath());\n // Ensure mode even if file already existed (chmod is idempotent).\n try {\n await fs.chmod(registryPath(), 0o600);\n } catch {\n // best effort\n }\n}\n\nfunction defaultIsAlive(pid: number): boolean {\n try {\n // Signal 0 probes existence without sending anything.\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\nexport async function registerAgent(entry: AgentEntry): Promise<void> {\n const current = await readRaw();\n const filtered = current.filter((e) => e.name !== entry.name);\n if (filtered.length !== current.length) {\n log.warn(\"registry\", `replacing existing entry for name=\"${entry.name}\"`);\n }\n filtered.push(entry);\n await writeAtomic(filtered);\n}\n\nexport async function unregisterAgent(name: string): Promise<void> {\n const current = await readRaw();\n const next = current.filter((e) => e.name !== name);\n if (next.length !== current.length) {\n await writeAtomic(next);\n }\n}\n\nexport async function listAgents(opts: ListOptions = {}): Promise<AgentEntry[]> {\n const isAlive = opts.isAlive ?? defaultIsAlive;\n const raw = await readRaw();\n const alive = raw.filter((e) => isAlive(e.pid));\n if (opts.prune && alive.length !== raw.length) {\n await writeAtomic(alive);\n }\n return alive;\n}\n\nexport async function findAgent(name: string): Promise<AgentEntry | null> {\n const all = await listAgents();\n return all.find((e) => e.name === name) ?? null;\n}\n","import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { findAgent } from \"./server/registry.js\";\nimport type { DelegationResult } from \"./delegate.js\";\nimport { log } from \"./logger.js\";\n\nexport interface RemoteDelegateOptions {\n context?: string;\n timeoutMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 120_000;\n\n/**\n * Dial another aman-agent running as an A2A server on the same machine\n * and run a task through its `agent.delegate` MCP tool. Returns a\n * DelegationResult matching the shape of the local `delegateTask` so\n * callers can treat local and remote delegation uniformly.\n *\n * Trust model: same user, same machine — bearer comes from the local\n * registry file (mode 0600). See plan docs for the broader discussion.\n */\nexport async function delegateRemote(\n task: string,\n agentName: string,\n options: RemoteDelegateOptions = {},\n): Promise<DelegationResult> {\n const entry = await findAgent(agentName);\n if (!entry) {\n return {\n profile: `@${agentName}`,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: `agent not found: ${agentName}`,\n };\n }\n\n const url = new URL(`http://127.0.0.1:${entry.port}/mcp`);\n const transport = new StreamableHTTPClientTransport(url, {\n requestInit: {\n headers: { Authorization: `Bearer ${entry.token}` },\n },\n // Disable SSE reconnection scheduling. On close(), the SDK aborts\n // the controller; without this override, the SSE stream's error\n // handler races to schedule a new _reconnectionTimeout AFTER close()\n // cleared the old one, and the timer (plus its referenced socket)\n // pins Node's event loop until the undici keepalive times out. A\n // delegateRemote caller then can't exit cleanly. maxRetries: 0\n // drops the schedule-on-error path entirely; we're doing a single\n // RPC, not a persistent stream, so reconnection has no value here.\n reconnectionOptions: {\n maxRetries: 0,\n initialReconnectionDelay: 1,\n maxReconnectionDelay: 1,\n reconnectionDelayGrowFactor: 1,\n },\n });\n const client = new Client({ name: \"aman-agent-a2a-caller\", version: \"0.1.0\" });\n\n try {\n await client.connect(transport);\n\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const call = client.callTool({\n name: \"agent.delegate\",\n arguments: {\n task,\n ...(options.context ? { context: options.context } : {}),\n },\n });\n\n // Promise.race picks a winner but does NOT cancel the losing promise's\n // resources. Capturing the timer id lets us clear it after the call\n // resolves — otherwise the setTimeout keeps a Timeout handle alive for\n // the full timeoutMs (120 s default) and pins Node's event loop long\n // after the caller thinks the RPC is done. Equivalent effect to using\n // AbortSignal.timeout() but keeps the existing error message.\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<never>((_, rej) => {\n timeoutId = setTimeout(\n () => rej(new Error(`remote delegate timed out after ${timeoutMs}ms`)),\n timeoutMs,\n );\n });\n let result;\n try {\n result = await Promise.race([call, timeout]);\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n }\n\n const text = Array.isArray(result.content)\n ? (result.content as Array<{ type: string; text?: string }>)\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\")\n : \"\";\n\n // MCP tool-level errors arrive as { isError: true, content: [{text: \"...\"}] }.\n // Surface them distinctly from JSON.parse failures and from empty responses.\n if ((result as { isError?: boolean }).isError) {\n return {\n profile: `@${agentName}`,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: `remote tool error: ${text || \"(no details)\"}`,\n };\n }\n\n const parsed = text ? JSON.parse(text) : { ok: false, error: \"empty response\" };\n\n log.debug(\"delegate-remote\", `@${agentName} ok=${parsed.ok}`);\n\n if (!parsed.ok) {\n return {\n profile: `@${agentName}`,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: parsed.error ?? \"unknown remote error\",\n };\n }\n\n return {\n profile: `@${agentName}`,\n task,\n response: parsed.text ?? \"\",\n toolsUsed: parsed.tools_used ?? [],\n turns: parsed.turns ?? 0,\n success: true,\n };\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n const lower = msg.toLowerCase();\n const normalized =\n lower.includes(\"401\") || lower.includes(\"unauthor\")\n ? `unauthorized: ${msg}`\n : msg;\n return {\n profile: `@${agentName}`,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: normalized,\n };\n } finally {\n // Teardown order matters:\n // 1. terminateSession() sends an MCP DELETE to drop the server-side\n // session. This needs the transport's abort controller to still\n // be alive, so it MUST run BEFORE client.close() (which aborts\n // the controller). Earlier order threw DOMException[AbortError].\n // 2. client.close() then releases SDK-side state and aborts the\n // transport. Combined with reconnectionOptions: { maxRetries: 0 }\n // on construction, this leaves zero handles pinning the event\n // loop — verified via process.getActiveResourcesInfo() === [].\n // 3. transport.close() is a no-op after client.close() (which\n // transitively closes the transport) but kept for symmetry.\n // All three are best-effort: any throw here is swallowed so a\n // teardown failure never masks a real result from the caller.\n try { await transport.terminateSession(); } catch { /* best effort */ }\n try { await client.close(); } catch { /* best effort */ }\n try { await transport.close(); } catch { /* best effort */ }\n }\n}\n","import pc from \"picocolors\";\nimport type {\n LLMClient,\n Message,\n ToolDefinition,\n ToolResultBlock,\n StreamChunk,\n} from \"./llm/types.js\";\nimport type { McpManager } from \"./mcp/client.js\";\nimport { assembleSystemPrompt } from \"./prompt.js\";\nimport { withRetry } from \"./retry.js\";\nimport { log } from \"./logger.js\";\nimport { onBeforeToolExec, type HookContext } from \"./hooks.js\";\nimport { memoryRecall } from \"./memory.js\";\nimport type { HooksConfig } from \"./config.js\";\n\nexport interface DelegationResult {\n profile: string;\n task: string;\n response: string;\n toolsUsed: string[];\n turns: number;\n success: boolean;\n error?: string;\n}\n\nexport interface DelegateOptions {\n maxTurns?: number; // max tool loop iterations (default: 10)\n silent?: boolean; // suppress output (default: false)\n tools?: ToolDefinition[]; // tools available to sub-agent\n hooksConfig?: HooksConfig; // guardrail config for tool execution\n}\n\nconst isRetryable = (err: unknown): boolean => {\n if (err instanceof Error) {\n const msg = err.message.toLowerCase();\n return msg.includes(\"rate\") || msg.includes(\"timeout\") || msg.includes(\"econnreset\");\n }\n return false;\n};\n\n/**\n * Run a task with a specific profile as a non-interactive sub-agent.\n * The sub-agent gets its own system prompt (from profile), runs a mini agent loop\n * (LLM → tools → LLM → ...), and returns the final text response.\n *\n * Reuses the parent's LLM client and MCP connections.\n */\nexport async function delegateTask(\n task: string,\n profile: string,\n client: LLMClient,\n mcpManager: McpManager,\n options: DelegateOptions = {},\n): Promise<DelegationResult> {\n // Route @name profiles to remote delegation via MCP server mode.\n if (profile.startsWith(\"@\")) {\n const remoteName = profile.slice(1).trim();\n if (!remoteName) {\n return {\n profile,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: \"empty remote agent name\",\n };\n }\n // Lazy import to insulate against future cycles between delegate.ts\n // and delegate-remote.ts → server/*. The static graph is acyclic today\n // but this is cheap insurance.\n const { delegateRemote } = await import(\"./delegate-remote.js\");\n return delegateRemote(task, remoteName, {});\n }\n\n const maxTurns = options.maxTurns ?? 10;\n const silent = options.silent ?? false;\n const tools = options.tools;\n\n try {\n // Load profile-specific system prompt\n const { prompt: systemPrompt } = assembleSystemPrompt(undefined, profile);\n\n // Build the delegation prompt\n const delegationPrompt = `${systemPrompt}\n\n<delegation>\nYou are being delegated a specific task by the primary agent. Complete this task thoroughly and return your result. You have access to tools if needed. Focus on the task — do not ask follow-up questions, just do your best with what you have.\n</delegation>`;\n\n // Inject relevant memories into delegation prompt\n let finalDelegationPrompt = delegationPrompt;\n try {\n const recall = await memoryRecall(task, { limit: 3, compact: true });\n if (recall.total > 0) {\n finalDelegationPrompt += `\\n\\n<relevant-memories>\\n${recall.text}\\n</relevant-memories>`;\n }\n } catch { /* memory unavailable, proceed without */ }\n\n const messages: Message[] = [\n { role: \"user\", content: task },\n ];\n\n const toolsUsed: string[] = [];\n let turns = 0;\n\n // Collect streamed text\n const onChunk: (chunk: StreamChunk) => void = silent\n ? () => {}\n : (chunk) => {\n if (chunk.type === \"text\" && chunk.text) {\n process.stdout.write(chunk.text);\n }\n };\n\n // Initial LLM call\n let response = await withRetry(\n () => client.chat(finalDelegationPrompt, messages, onChunk, tools),\n { maxAttempts: 2, baseDelay: 1000, retryable: isRetryable },\n );\n\n messages.push(response.message);\n\n // Tool loop (same pattern as agent.ts)\n while (response.toolUses.length > 0 && turns < maxTurns) {\n turns++;\n\n const toolResults: ToolResultBlock[] = await Promise.all(\n response.toolUses.map(async (toolUse) => {\n if (!silent) {\n process.stdout.write(pc.dim(` [${profile}:${toolUse.name}...]\\n`));\n }\n toolsUsed.push(toolUse.name);\n\n // Guardrail check (same as main agent)\n if (options.hooksConfig?.rulesCheck) {\n try {\n const hookCtx: HookContext = { mcpManager, config: options.hooksConfig };\n const check = await onBeforeToolExec(toolUse.name, toolUse.input, hookCtx);\n if (!check.allow) {\n return {\n type: \"tool_result\" as const,\n tool_use_id: toolUse.id,\n content: `BLOCKED by guardrail: ${check.reason}`,\n is_error: true,\n };\n }\n } catch { /* guardrail check failed, allow */ }\n }\n\n try {\n const result = await mcpManager.callTool(toolUse.name, toolUse.input);\n return {\n type: \"tool_result\" as const,\n tool_use_id: toolUse.id,\n content: result,\n };\n } catch (err) {\n return {\n type: \"tool_result\" as const,\n tool_use_id: toolUse.id,\n content: `Error: ${err instanceof Error ? err.message : String(err)}`,\n is_error: true,\n };\n }\n }),\n );\n\n messages.push({ role: \"user\", content: toolResults });\n\n response = await withRetry(\n () => client.chat(finalDelegationPrompt, messages, onChunk, tools),\n { maxAttempts: 2, baseDelay: 1000, retryable: isRetryable },\n );\n\n messages.push(response.message);\n }\n\n // Extract final text response\n const finalMessage = response.message;\n const responseText = typeof finalMessage.content === \"string\"\n ? finalMessage.content\n : finalMessage.content\n .filter((b) => b.type === \"text\")\n .map((b) => (\"text\" in b ? b.text : \"\"))\n .join(\"\");\n\n return {\n profile,\n task,\n response: responseText,\n toolsUsed: [...new Set(toolsUsed)],\n turns,\n success: true,\n };\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n log.warn(\"delegate\", `Delegation to ${profile} failed: ${error}`);\n return {\n profile,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error,\n };\n }\n}\n\n/**\n * Delegate a task to multiple profiles in parallel.\n * Useful for: write + review, research + summarize, etc.\n */\nexport async function delegateParallel(\n tasks: Array<{ task: string; profile: string }>,\n client: LLMClient,\n mcpManager: McpManager,\n options: DelegateOptions = {},\n): Promise<DelegationResult[]> {\n return Promise.all(\n tasks.map(({ task, profile }) =>\n delegateTask(task, profile, client, mcpManager, { ...options, silent: true }),\n ),\n );\n}\n\n/**\n * Delegate a pipeline of tasks sequentially — each task receives the previous result.\n * Useful for: draft → review → polish pipelines.\n */\nexport async function delegatePipeline(\n steps: Array<{ profile: string; taskTemplate: string }>,\n initialInput: string,\n client: LLMClient,\n mcpManager: McpManager,\n options: DelegateOptions = {},\n): Promise<DelegationResult[]> {\n const results: DelegationResult[] = [];\n let previousResult = initialInput;\n\n for (const step of steps) {\n const task = step.taskTemplate.replace(\"{{input}}\", previousResult);\n\n if (!options.silent) {\n process.stdout.write(pc.dim(`\\n [delegating to ${step.profile}...]\\n`));\n }\n\n const result = await delegateTask(task, step.profile, client, mcpManager, {\n ...options,\n silent: true,\n });\n results.push(result);\n\n if (!result.success) break;\n previousResult = result.response;\n }\n\n return results;\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { estimateTokens, buildBudgetedPrompt } from \"./token-budget.js\";\nimport type { PromptComponent } from \"./token-budget.js\";\nimport { loadUserIdentity, formatUserContext } from \"./user-identity.js\";\n\ninterface EcosystemFile {\n name: string;\n dir: string;\n file: string;\n profileOverridable?: boolean; // can be overridden by profile-specific file\n}\n\nconst ECOSYSTEM_FILES: EcosystemFile[] = [\n { name: \"identity\", dir: \".acore\", file: \"core.md\", profileOverridable: true },\n { name: \"tools\", dir: \".akit\", file: \"kit.md\" },\n { name: \"workflows\", dir: \".aflow\", file: \"flow.md\" },\n { name: \"guardrails\", dir: \".arules\", file: \"rules.md\", profileOverridable: true },\n { name: \"skills\", dir: \".askill\", file: \"skills.md\", profileOverridable: true },\n];\n\n/**\n * Resolve the file path for an ecosystem layer, checking profile override first.\n */\nfunction resolveLayerPath(entry: EcosystemFile, home: string, profile?: string): string | null {\n // Check profile-specific override first\n if (profile && entry.profileOverridable) {\n const profilePath = path.join(home, \".acore\", \"profiles\", profile, entry.file);\n if (fs.existsSync(profilePath)) return profilePath;\n\n // For rules/skills, also check profile dir with original filename\n if (entry.name === \"guardrails\") {\n const altPath = path.join(home, \".acore\", \"profiles\", profile, \"rules.md\");\n if (fs.existsSync(altPath)) return altPath;\n }\n if (entry.name === \"skills\") {\n const altPath = path.join(home, \".acore\", \"profiles\", profile, \"skills.md\");\n if (fs.existsSync(altPath)) return altPath;\n }\n }\n\n // Fall back to global path\n const globalPath = path.join(home, entry.dir, entry.file);\n if (fs.existsSync(globalPath)) return globalPath;\n\n return null;\n}\n\nexport function assembleSystemPrompt(\n maxTokens?: number,\n profile?: string,\n): {\n prompt: string;\n layers: string[];\n truncated: string[];\n totalTokens: number;\n profile?: string;\n} {\n const home = os.homedir();\n const components: PromptComponent[] = [];\n\n for (const entry of ECOSYSTEM_FILES) {\n const filePath = resolveLayerPath(entry, home, profile);\n if (filePath) {\n const content = fs.readFileSync(filePath, \"utf-8\").trim();\n components.push({\n name: entry.name,\n content,\n tokens: estimateTokens(content),\n });\n }\n }\n\n // Project context (not prioritized — appended as extra)\n const contextPath = path.join(process.cwd(), \".acore\", \"context.md\");\n if (fs.existsSync(contextPath)) {\n const content = fs.readFileSync(contextPath, \"utf-8\").trim();\n components.push({\n name: \"context\",\n content,\n tokens: estimateTokens(content),\n });\n }\n\n // User identity — always included if available (high priority, low token cost)\n const userIdentity = loadUserIdentity();\n if (userIdentity) {\n const userContent = formatUserContext(userIdentity);\n components.push({\n name: \"user\",\n content: userContent,\n tokens: estimateTokens(userContent),\n });\n }\n\n const budgeted = buildBudgetedPrompt(components, maxTokens);\n\n return {\n prompt: budgeted.prompt,\n layers: budgeted.included,\n truncated: budgeted.truncated,\n totalTokens: budgeted.totalTokens,\n profile,\n };\n}\n\n/**\n * List available profiles.\n */\nexport function listProfiles(): Array<{ name: string; aiName: string; personality: string }> {\n const profilesDir = path.join(os.homedir(), \".acore\", \"profiles\");\n if (!fs.existsSync(profilesDir)) return [];\n\n const profiles: Array<{ name: string; aiName: string; personality: string }> = [];\n for (const entry of fs.readdirSync(profilesDir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const corePath = path.join(profilesDir, entry.name, \"core.md\");\n if (!fs.existsSync(corePath)) continue;\n\n const content = fs.readFileSync(corePath, \"utf-8\");\n const nameMatch = content.match(/^# (.+)/m);\n const personalityMatch = content.match(/- Personality:\\s*(.+)/);\n\n profiles.push({\n name: entry.name,\n aiName: nameMatch?.[1]?.trim() || entry.name,\n personality: personalityMatch?.[1]?.trim() || \"default\",\n });\n }\n\n return profiles;\n}\n\n/**\n * Get the AI name for a profile (or default).\n */\nexport function getProfileAiName(profile?: string): string {\n const home = os.homedir();\n let corePath: string;\n\n if (profile) {\n const profileCorePath = path.join(home, \".acore\", \"profiles\", profile, \"core.md\");\n corePath = fs.existsSync(profileCorePath) ? profileCorePath : path.join(home, \".acore\", \"core.md\");\n } else {\n corePath = path.join(home, \".acore\", \"core.md\");\n }\n\n if (!fs.existsSync(corePath)) return \"Assistant\";\n const content = fs.readFileSync(corePath, \"utf-8\");\n const match = content.match(/^# (.+)$/m);\n return match?.[1]?.trim() || \"Assistant\";\n}\n","// Rough token estimation: ~1.3 tokens per word for English markdown\nexport function estimateTokens(text: string): number {\n return Math.round(text.split(/\\s+/).filter(Boolean).length * 1.3);\n}\n\n// Priority order for system prompt components (highest to lowest)\nconst PRIORITIES = [\n \"identity\", // core.md — always include\n \"user\", // user.md — user profile, always include\n \"guardrails\", // rules.md — safety critical\n \"workflows\", // flow.md — behavioral\n \"tools\", // kit.md — capabilities\n \"skills\", // skills.md — can be truncated\n];\n\nexport interface PromptComponent {\n name: string;\n content: string;\n tokens: number;\n}\n\nexport function buildBudgetedPrompt(\n components: PromptComponent[],\n maxTokens: number = 8000, // default budget for system prompt\n): { prompt: string; included: string[]; truncated: string[]; totalTokens: number } {\n const included: string[] = [];\n const truncated: string[] = [];\n const parts: string[] = [];\n let totalTokens = 0;\n\n // Sort by priority\n const sorted = [...components].sort((a, b) => {\n const aPri = PRIORITIES.indexOf(a.name);\n const bPri = PRIORITIES.indexOf(b.name);\n return (aPri === -1 ? 99 : aPri) - (bPri === -1 ? 99 : bPri);\n });\n\n for (const comp of sorted) {\n if (totalTokens + comp.tokens <= maxTokens) {\n parts.push(comp.content);\n included.push(comp.name);\n totalTokens += comp.tokens;\n } else {\n // Try to include a truncated version (first 50% of content)\n const halfContent = comp.content.slice(0, Math.floor(comp.content.length / 2));\n const halfTokens = estimateTokens(halfContent);\n if (totalTokens + halfTokens <= maxTokens) {\n parts.push(halfContent + \"\\n\\n[... truncated for context budget ...]\");\n included.push(comp.name + \" (partial)\");\n totalTokens += halfTokens;\n } else {\n truncated.push(comp.name);\n }\n }\n }\n\n return {\n prompt: parts.join(\"\\n\\n---\\n\\n\"),\n included,\n truncated,\n totalTokens,\n };\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { identityDir } from \"./config.js\";\n\nexport interface UserIdentity {\n name: string;\n role: \"developer\" | \"designer\" | \"student\" | \"manager\" | \"generalist\";\n roleLabel: string;\n expertise: \"beginner\" | \"intermediate\" | \"advanced\" | \"expert\";\n expertiseLabel: string;\n style: \"concise\" | \"balanced\" | \"thorough\" | \"socratic\";\n styleLabel: string;\n workingOn?: string;\n notes?: string;\n createdAt: string;\n updatedAt: string;\n}\n\nconst USER_FILE = path.join(identityDir(), \"user.md\");\n\n/**\n * Check if user identity exists.\n */\nexport function hasUserIdentity(): boolean {\n return fs.existsSync(USER_FILE);\n}\n\n/**\n * Load user identity from ~/.acore/user.md.\n * Returns null if file doesn't exist or is malformed.\n */\nexport function loadUserIdentity(): UserIdentity | null {\n if (!fs.existsSync(USER_FILE)) return null;\n\n try {\n const content = fs.readFileSync(USER_FILE, \"utf-8\");\n\n const get = (key: string): string => {\n const match = content.match(new RegExp(`^- ${key}:\\\\s*(.+)$`, \"m\"));\n return match?.[1]?.trim() ?? \"\";\n };\n\n const getSection = (heading: string): string => {\n const pattern = new RegExp(`## ${heading}\\\\n([\\\\s\\\\S]*?)(?=\\\\n## |$)`);\n const match = content.match(pattern);\n if (!match) return \"\";\n // Strip leading \"- Key: value\" lines, return freeform text\n return match[1]\n .split(\"\\n\")\n .filter((line) => !line.startsWith(\"- \") && line.trim().length > 0)\n .join(\"\\n\")\n .trim();\n };\n\n const name = get(\"Name\");\n if (!name) return null;\n\n return {\n name,\n role: (get(\"Role\") || \"generalist\") as UserIdentity[\"role\"],\n roleLabel: get(\"Role Label\") || get(\"Role\") || \"Generalist\",\n expertise: (get(\"Expertise\") || \"intermediate\") as UserIdentity[\"expertise\"],\n expertiseLabel: get(\"Expertise Label\") || get(\"Expertise\") || \"Intermediate\",\n style: (get(\"Style\") || \"balanced\") as UserIdentity[\"style\"],\n styleLabel: get(\"Style Label\") || get(\"Style\") || \"Balanced\",\n workingOn: getSection(\"Working On\") || undefined,\n notes: getSection(\"Notes\") || undefined,\n createdAt: get(\"Created\") || new Date().toISOString().split(\"T\")[0],\n updatedAt: get(\"Updated\") || new Date().toISOString().split(\"T\")[0],\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Save user identity to ~/.acore/user.md.\n */\nexport function saveUserIdentity(user: UserIdentity): void {\n const dir = path.dirname(USER_FILE);\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n\n const lines: string[] = [\n \"# User Profile\",\n \"\",\n \"## About\",\n `- Name: ${user.name}`,\n `- Role: ${user.role}`,\n `- Role Label: ${user.roleLabel}`,\n `- Expertise: ${user.expertise}`,\n `- Expertise Label: ${user.expertiseLabel}`,\n `- Style: ${user.style}`,\n `- Style Label: ${user.styleLabel}`,\n ];\n\n if (user.workingOn) {\n lines.push(\"\", \"## Working On\", user.workingOn);\n }\n\n if (user.notes) {\n lines.push(\"\", \"## Notes\", user.notes);\n }\n\n lines.push(\n \"\",\n \"## Meta\",\n `- Created: ${user.createdAt}`,\n `- Updated: ${user.updatedAt}`,\n );\n\n fs.writeFileSync(USER_FILE, lines.join(\"\\n\") + \"\\n\", \"utf-8\");\n}\n\n/**\n * Format user identity for injection into system prompt.\n */\nexport function formatUserContext(user: UserIdentity): string {\n const parts: string[] = [\n `<user-profile>`,\n `The person you're talking to:`,\n `- Name: ${user.name}`,\n `- Role: ${user.roleLabel}`,\n `- Expertise: ${user.expertiseLabel}`,\n ];\n\n // Style instructions\n switch (user.style) {\n case \"concise\":\n parts.push(\"- Prefers: short, direct answers. Code first, explain after. No fluff.\");\n break;\n case \"balanced\":\n parts.push(\"- Prefers: explain the reasoning briefly, then show the solution.\");\n break;\n case \"thorough\":\n parts.push(\"- Prefers: detailed explanations with context. Help them understand deeply.\");\n break;\n case \"socratic\":\n parts.push(\"- Prefers: ask guiding questions. Help them figure it out themselves.\");\n break;\n }\n\n // Expertise calibration\n switch (user.expertise) {\n case \"beginner\":\n parts.push(\"- Calibration: explain concepts clearly, define terms, show examples. Be patient.\");\n break;\n case \"intermediate\":\n parts.push(\"- Calibration: skip basic explanations, focus on the task. Explain non-obvious things.\");\n break;\n case \"advanced\":\n parts.push(\"- Calibration: be direct. Skip explanations unless asked. Focus on edge cases and trade-offs.\");\n break;\n case \"expert\":\n parts.push(\"- Calibration: peer-level discussion. Challenge assumptions. Focus on architecture and nuance.\");\n break;\n }\n\n if (user.workingOn) {\n parts.push(`- Currently working on: ${user.workingOn}`);\n }\n\n if (user.notes) {\n parts.push(`- Notes: ${user.notes}`);\n }\n\n parts.push(\n \"\",\n `Use their name naturally (not every message). Adapt to their level and style.`,\n `</user-profile>`,\n );\n\n return parts.join(\"\\n\");\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\n\nexport interface HooksConfig {\n memoryRecall?: boolean;\n sessionResume?: boolean;\n rulesCheck?: boolean;\n workflowSuggest?: boolean;\n evalPrompt?: boolean;\n autoSessionSave?: boolean;\n extractMemories?: boolean;\n featureHints?: boolean;\n personalityAdapt?: boolean;\n recordObservations?: boolean;\n autoPostmortem?: boolean;\n}\n\nconst DEFAULT_HOOKS: HooksConfig = {\n memoryRecall: true,\n sessionResume: true,\n rulesCheck: true,\n workflowSuggest: true,\n evalPrompt: true,\n autoSessionSave: true,\n extractMemories: true,\n featureHints: true,\n personalityAdapt: true,\n};\n\nexport interface McpServerEntry {\n command: string;\n args: string[];\n env?: Record<string, string>;\n}\n\nexport interface MemoryConfig {\n maxStaleDays?: number;\n minConfidence?: number;\n minAccessCount?: number;\n maxRecallTokens?: number;\n}\n\nexport interface AgentConfig {\n provider: \"anthropic\" | \"openai\" | \"ollama\" | \"claude-code\" | \"copilot\";\n apiKey: string;\n model: string;\n ollamaUrl?: string;\n maxOutputTokens?: number;\n hooks?: HooksConfig;\n mcpServers?: Record<string, McpServerEntry>;\n memory?: MemoryConfig;\n orchestrator?: {\n maxParallelTasks?: number;\n defaultTier?: \"fast\" | \"standard\" | \"advanced\";\n requireApprovalForPhaseTransition?: boolean;\n taskTimeoutMs?: number;\n orchestrationTimeoutMs?: number;\n };\n github?: {\n defaultRepo?: string; // owner/repo format\n defaultBranch?: string; // default: \"main\"\n autoCreatePR?: boolean; // auto-create PR after orchestration\n ciGateEnabled?: boolean; // wait for CI before merging\n };\n}\n\n/**\n * Resolve the aman-agent home directory.\n * Priority: $AMAN_HOME > $AMAN_AGENT_HOME > ~/.aman-agent\n *\n * Previously `configDir()` was the sole entry point and only checked\n * `AMAN_AGENT_HOME`. Now `homeDir()` is canonical, and `configDir()`\n * delegates to it. Recorded as feedback memory\n * `feedback_aman_agent_hermetic_tests.md`.\n */\nexport function homeDir(): string {\n return process.env.AMAN_HOME || process.env.AMAN_AGENT_HOME || path.join(os.homedir(), \".aman-agent\");\n}\n\nexport function identityDir(): string { return path.join(homeDir(), \"identity\"); }\nexport function rulesDir(): string { return path.join(homeDir(), \"rules\"); }\nexport function memoryDir(): string { return path.join(homeDir(), \"memory\"); }\nexport function workflowsDir(): string { return path.join(homeDir(), \"workflows\"); }\nexport function skillsDir(): string { return path.join(homeDir(), \"skills\"); }\nexport function evalDir(): string { return path.join(homeDir(), \"eval\"); }\n\nfunction configDir(): string {\n return homeDir();\n}\n\nfunction configPath(): string {\n return path.join(configDir(), \"config.json\");\n}\n\nexport function loadConfig(): AgentConfig | null {\n const p = configPath();\n if (!fs.existsSync(p)) return null;\n try {\n const raw = JSON.parse(fs.readFileSync(p, \"utf-8\")) as AgentConfig;\n raw.hooks = { ...DEFAULT_HOOKS, ...raw.hooks };\n return raw;\n } catch {\n return null;\n }\n}\n\nexport function saveConfig(config: AgentConfig): void {\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(\n configPath(),\n JSON.stringify(config, null, 2) + \"\\n\",\n \"utf-8\",\n );\n}\n\nexport function configExists(): boolean {\n return fs.existsSync(configPath());\n}\n","export interface RetryOptions {\n maxAttempts: number;\n baseDelay: number;\n retryable: (err: Error) => boolean;\n}\n\nexport async function withRetry<T>(\n fn: () => Promise<T>,\n options: RetryOptions,\n): Promise<T> {\n const { maxAttempts, baseDelay, retryable } = options;\n let lastError: Error | undefined;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await fn();\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (!retryable(lastError) || attempt === maxAttempts) {\n throw lastError;\n }\n const delay = baseDelay * Math.pow(2, attempt - 1) * (0.5 + Math.random() * 0.5);\n await new Promise((resolve) => setTimeout(resolve, delay));\n }\n }\n\n throw lastError;\n}\n","import pc from \"picocolors\";\nimport * as p from \"@clack/prompts\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport type { McpManager } from \"./mcp/client.js\";\nimport type { LLMClient, Message } from \"./llm/types.js\";\nimport type { HooksConfig } from \"./config.js\";\nimport { log } from \"./logger.js\";\nimport {\n computePersonality,\n syncPersonalityToCore,\n formatWellbeingNudge,\n shouldFireNudge,\n} from \"./personality.js\";\nimport { memoryRecall, memoryContext, reminderCheck, memoryLog, isMemoryInitialized, memoryStore } from \"./memory.js\";\nimport { loadUserIdentity } from \"./user-identity.js\";\nimport { shouldAutoPostmortem, generatePostmortemReport, savePostmortem } from \"./postmortem.js\";\nimport {\n validateCandidate,\n writeSkillToFile,\n mergeSkillInFile,\n appendCrystallizationLog,\n appendRejection,\n loadRejectedNames,\n incrementSuggestionCount,\n loadSuggestionCounts,\n} from \"./crystallization.js\";\nimport type { ObservationSession } from \"./observation.js\";\nimport {\n loadUserModel,\n saveUserModel,\n createEmptyModel,\n aggregateSession,\n computeProfile,\n feedForward,\n type SessionSnapshot,\n type PersonalityOverrides,\n} from \"./user-model.js\";\n\nfunction getTimeContext(): string {\n const now = new Date();\n const hour = now.getHours();\n const days = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\n const day = days[now.getDay()];\n\n let period: string;\n if (hour < 6) period = \"late-night\";\n else if (hour < 12) period = \"morning\";\n else if (hour < 17) period = \"afternoon\";\n else if (hour < 21) period = \"evening\";\n else period = \"night\";\n\n const timeStr = now.toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\" });\n const dateStr = now.toLocaleDateString();\n\n return `<time-context>\\nCurrent time: ${dateStr} ${timeStr} (${period}, ${day})\\nAdapt your tone naturally — don't announce the time, just be contextually appropriate.\\n</time-context>`;\n}\n\nexport interface HookContext {\n mcpManager: McpManager;\n config: HooksConfig;\n llmClient?: LLMClient; // needed for auto-postmortem generation\n}\n\nlet isHookCall = false;\nlet sessionStartTime: number = Date.now();\n\nexport function getSessionStartTime(): number {\n return sessionStartTime;\n}\n\nexport async function onSessionStart(\n ctx: HookContext,\n): Promise<{ greeting?: string; contextInjection?: string; firstRun?: boolean; visibleReminders?: string[]; resumeTopic?: string }> {\n let greeting = \"\";\n let contextInjection = \"\";\n let firstRun = false;\n let resumeTopic: string | undefined;\n const visibleReminders: string[] = [];\n\n // Detect first run via memory_recall\n if (!isMemoryInitialized()) {\n // Memory system failed to init — skip memory operations but don't treat as first run\n firstRun = false;\n } else {\n try {\n isHookCall = true;\n const recallResult = await memoryRecall(\"*\", { limit: 1 });\n firstRun = recallResult.total === 0;\n } catch {\n firstRun = true;\n } finally {\n isHookCall = false;\n }\n }\n\n if (firstRun) {\n const userIdentity = loadUserIdentity();\n\n if (userIdentity) {\n // First run WITH user profile — personalized introduction\n contextInjection = `<first-session>\nThis is your FIRST conversation with ${userIdentity.name}. They just set up their profile:\n- Role: ${userIdentity.roleLabel}\n- Expertise: ${userIdentity.expertiseLabel}\n- Style preference: ${userIdentity.styleLabel}\n${userIdentity.workingOn ? `- Working on: ${userIdentity.workingOn}` : \"\"}\n${userIdentity.notes ? `- Notes: ${userIdentity.notes}` : \"\"}\n\nIntroduce yourself warmly:\n- Greet them by name\n- Acknowledge what they do and what they're working on (if provided)\n- Show you understand their style preference (e.g., if they want concise answers, keep it tight)\n- Mention you'll remember what matters across conversations\n- Keep it to 3-5 sentences, natural tone — make them feel like you GET them\n</first-session>`;\n } else {\n // First run WITHOUT user profile — generic introduction\n contextInjection = `<first-session>\nThis is your FIRST conversation with this user. Introduce yourself warmly:\n- Share your name and that you're their personal AI companion\n- Mention you'll remember what matters across conversations\n- Ask what they'd like to be called\n- Mention they can set up their profile with /profile edit for a more personalized experience\n- Keep it to 3-4 sentences, natural tone\n</first-session>`;\n }\n\n // Still add time context\n const timeContext = getTimeContext();\n contextInjection = `<session-context>\\n${timeContext}\\n</session-context>\\n${contextInjection}`;\n\n return {\n greeting: undefined,\n contextInjection,\n firstRun,\n visibleReminders,\n resumeTopic: undefined,\n };\n }\n\n // Returning user flow\n if (ctx.config.memoryRecall) {\n try {\n isHookCall = true;\n const contextResult = await memoryContext(\"session context\");\n if (contextResult.memoriesUsed > 0) {\n greeting += contextResult.text;\n }\n } catch (err) {\n log.warn(\"hooks\", \"memory_context recall failed\", err);\n } finally {\n isHookCall = false;\n }\n }\n\n if (ctx.config.sessionResume) {\n try {\n isHookCall = true;\n const result = await ctx.mcpManager.callTool(\"identity_summary\", {});\n if (result && !result.startsWith(\"Error\")) {\n if (greeting) greeting += \"\\n\";\n greeting += result;\n\n // Extract resume topic\n const topicMatch = result.match(/(?:resume|last|topic)[:\\s]*(.+?)(?:\\n|$)/i);\n if (topicMatch) {\n resumeTopic = topicMatch[1].trim();\n }\n }\n } catch (err) {\n log.warn(\"hooks\", \"identity_summary failed\", err);\n } finally {\n isHookCall = false;\n }\n }\n\n // Time context\n const timeContext = getTimeContext();\n if (greeting) greeting += \"\\n\" + timeContext;\n else greeting = timeContext;\n\n // Check reminders\n try {\n isHookCall = true;\n const reminders = reminderCheck();\n if (reminders.length > 0) {\n const reminderText = reminders.map(r => r.content).join(\"\\n\");\n greeting += \"\\n\\n<pending-reminders>\\n\" + reminderText + \"\\n</pending-reminders>\";\n for (const r of reminders) {\n visibleReminders.push(r.content);\n }\n }\n } catch (err) {\n log.debug(\"hooks\", \"reminder_check failed\", err);\n } finally {\n isHookCall = false;\n }\n\n // Compute initial personality state (with feed-forward from user model)\n if (ctx.config.personalityAdapt !== false) {\n sessionStartTime = Date.now();\n const hour = new Date().getHours();\n let period: string;\n if (hour < 6) period = \"late-night\";\n else if (hour < 12) period = \"morning\";\n else if (hour < 17) period = \"afternoon\";\n else if (hour < 21) period = \"evening\";\n else period = \"night\";\n\n const state = computePersonality({\n timePeriod: period,\n sessionMinutes: 0,\n turnCount: 0,\n });\n\n // Load user model for feed-forward overrides\n try {\n const model = await loadUserModel();\n if (model) {\n const overrides = feedForward(model);\n if (overrides) {\n log.debug(\"hooks\", `Feed-forward active (trust=${model.profile.trustScore.toFixed(2)}, sessions=${model.profile.totalSessions})`);\n\n // Apply energy override (e.g., night owls stay \"steady\" instead of \"reflective\")\n if (overrides.energyOverride && (period === \"late-night\" || period === \"night\")) {\n (state as { energy: string }).energy = overrides.energyOverride as typeof state.energy;\n }\n\n // Apply default-to-Personal-mode when sentiment is worsening\n if (overrides.defaultToPersonalMode && state.activeMode === \"Default\") {\n (state as { activeMode: string }).activeMode = \"Personal\";\n }\n\n // Inject trust context into greeting\n if (overrides.compactGreeting) {\n greeting += \"\\n<user-model-context>High trust user (score: \" +\n model.profile.trustScore.toFixed(2) +\n \", \" + model.profile.totalSessions +\n \" sessions). Keep greeting compact — they know you well.</user-model-context>\";\n }\n\n // Surface sentiment trend if concerning\n if (model.profile.sentimentTrend === \"worsening\") {\n greeting += \"\\n<user-model-context>Sentiment trend is worsening across recent sessions. Be more attentive and patient.</user-model-context>\";\n }\n }\n }\n } catch (err) {\n log.debug(\"hooks\", \"user model feed-forward failed\", err);\n }\n\n // Sync to acore (fire-and-forget)\n syncPersonalityToCore(state, ctx.mcpManager).catch(() => {});\n\n // Add wellbeing nudge to context if applicable (with adaptive filtering)\n const nudge = formatWellbeingNudge(state);\n if (nudge && state.wellbeingNudge) {\n let fireNudge = true;\n try {\n const model = await loadUserModel();\n if (model && model.sessions.length >= 5) {\n const profile = computeProfile(model.sessions, model.sessions.length);\n fireNudge = shouldFireNudge(state.wellbeingNudge, profile);\n }\n } catch {\n // No model yet — always fire\n }\n if (fireNudge) {\n greeting += \"\\n\" + nudge;\n }\n }\n }\n\n if (greeting) {\n contextInjection = `<session-context>\\n${greeting}\\n</session-context>`;\n }\n\n return {\n greeting: greeting || undefined,\n contextInjection: contextInjection || undefined,\n firstRun,\n visibleReminders,\n resumeTopic,\n };\n}\n\nexport async function onBeforeToolExec(\n toolName: string,\n toolArgs: Record<string, unknown>,\n ctx: HookContext,\n): Promise<{ allow: boolean; reason?: string }> {\n if (!ctx.config.rulesCheck || isHookCall) {\n return { allow: true };\n }\n\n if (toolName === \"rules_check\") {\n return { allow: true };\n }\n\n try {\n isHookCall = true;\n const description = `${toolName}(${JSON.stringify(toolArgs)})`;\n const result = await ctx.mcpManager.callTool(\"rules_check\", {\n action: description,\n });\n\n try {\n const parsed = JSON.parse(result) as {\n violations?: string[];\n };\n if (parsed.violations && parsed.violations.length > 0) {\n return {\n allow: false,\n reason: parsed.violations.join(\"; \"),\n };\n }\n } catch (err) {\n log.debug(\"hooks\", \"rules_check parse failed\", err);\n }\n\n return { allow: true };\n } catch (err) {\n log.warn(\"hooks\", \"rules_check call failed\", err);\n return { allow: true };\n } finally {\n isHookCall = false;\n }\n}\n\nexport async function onWorkflowMatch(\n userInput: string,\n ctx: HookContext,\n): Promise<{ name: string; steps: string } | null> {\n if (!ctx.config.workflowSuggest) {\n return null;\n }\n\n try {\n isHookCall = true;\n const result = await ctx.mcpManager.callTool(\"workflow_list\", {});\n\n const workflows = JSON.parse(result) as Array<{\n name: string;\n description?: string;\n steps?: string[];\n }>;\n\n const inputLower = userInput.toLowerCase();\n\n for (const wf of workflows) {\n const nameLower = wf.name.toLowerCase();\n\n // Check if user input contains workflow name\n if (inputLower.includes(nameLower)) {\n const steps = (wf.steps || [])\n .map((s, i) => `${i + 1}. ${s}`)\n .join(\"\\n\");\n return { name: wf.name, steps };\n }\n\n // Check significant words from description\n if (wf.description) {\n const words = wf.description\n .split(/\\s+/)\n .filter((w) => w.length > 4)\n .map((w) => w.toLowerCase());\n\n for (const word of words) {\n if (inputLower.includes(word)) {\n const steps = (wf.steps || [])\n .map((s, i) => `${i + 1}. ${s}`)\n .join(\"\\n\");\n return { name: wf.name, steps };\n }\n }\n }\n }\n\n return null;\n } catch (err) {\n log.debug(\"hooks\", \"workflow_list failed\", err);\n return null;\n } finally {\n isHookCall = false;\n }\n}\n\nexport async function onSessionEnd(\n ctx: HookContext,\n messages: Message[],\n sessionId: string,\n observationSession?: ObservationSession,\n): Promise<void> {\n try {\n // Auto-save conversation to amem memory_log\n if (ctx.config.autoSessionSave && messages.length > 2) {\n console.log(pc.dim(\"\\n Saving conversation to memory...\"));\n\n // Save last 50 text messages to memory_log\n const textMessages = messages\n .filter((m) => typeof m.content === \"string\")\n .slice(-50);\n\n for (const msg of textMessages) {\n try {\n isHookCall = true;\n memoryLog(sessionId, msg.role, (msg.content as string).slice(0, 5000));\n } catch (err) {\n log.debug(\"hooks\", \"memory_log write failed for \" + sessionId, err);\n } finally {\n isHookCall = false;\n }\n }\n\n // Update session resume in identity\n let lastUserMsg = \"\";\n for (let i = messages.length - 1; i >= 0; i--) {\n if (\n messages[i].role === \"user\" &&\n typeof messages[i].content === \"string\"\n ) {\n lastUserMsg = messages[i].content as string;\n break;\n }\n }\n\n if (lastUserMsg) {\n try {\n isHookCall = true;\n await ctx.mcpManager.callTool(\"identity_update_session\", {\n resume: lastUserMsg.slice(0, 200),\n topics: \"See conversation history\",\n decisions: \"See conversation history\",\n });\n } finally {\n isHookCall = false;\n }\n }\n\n console.log(pc.dim(` Saved ${textMessages.length} messages (session: ${sessionId})`));\n }\n\n // Update per-project .acore/context.md if it exists\n const projectContextPath = path.join(process.cwd(), \".acore\", \"context.md\");\n if (fs.existsSync(projectContextPath) && messages.length > 2) {\n try {\n let contextContent = fs.readFileSync(projectContextPath, \"utf-8\");\n const now = new Date().toISOString().split(\"T\")[0];\n\n // Extract last user message for resume\n let lastUserMsg = \"\";\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === \"user\" && typeof messages[i].content === \"string\") {\n lastUserMsg = (messages[i].content as string).slice(0, 200);\n break;\n }\n }\n\n // Update Session section in context.md\n const sessionPattern = /## Session\\n[\\s\\S]*?(?=\\n## |$)/;\n if (sessionPattern.test(contextContent)) {\n const newSession = `## Session\\n- Last updated: ${now}\\n- Resume: ${lastUserMsg || \"See conversation history\"}\\n- Active topics: [see memory]\\n- Recent decisions: [see memory]\\n- Temp notes: [cleared]`;\n contextContent = contextContent.replace(sessionPattern, newSession);\n fs.writeFileSync(projectContextPath, contextContent, \"utf-8\");\n log.debug(\"hooks\", `Updated project context: ${projectContextPath}`);\n }\n } catch (err) {\n log.debug(\"hooks\", \"project context update failed\", err);\n }\n }\n\n // Persist final personality state\n const sessionMinutes = Math.round((Date.now() - sessionStartTime) / 60000);\n const hour = new Date().getHours();\n let period: string;\n if (hour < 6) period = \"late-night\";\n else if (hour < 12) period = \"morning\";\n else if (hour < 17) period = \"afternoon\";\n else if (hour < 21) period = \"evening\";\n else period = \"night\";\n\n const turnCount = messages.filter((m) => m.role === \"user\").length;\n const finalState = computePersonality({\n timePeriod: period,\n sessionMinutes,\n turnCount,\n });\n\n if (ctx.config.personalityAdapt !== false) {\n try {\n isHookCall = true;\n await syncPersonalityToCore(finalState, ctx.mcpManager);\n } finally {\n isHookCall = false;\n }\n }\n\n // Session rating prompt\n let sessionRating: string | undefined;\n if (ctx.config.evalPrompt) {\n const rating = await p.select({\n message: \"Quick rating for this session?\",\n options: [\n { value: \"great\", label: \"Great\" },\n { value: \"good\", label: \"Good\" },\n { value: \"okay\", label: \"Okay\" },\n { value: \"skip\", label: \"Skip\" },\n ],\n initialValue: \"skip\",\n });\n\n if (!p.isCancel(rating) && rating !== \"skip\") {\n sessionRating = rating as string;\n try {\n isHookCall = true;\n await ctx.mcpManager.callTool(\"eval_log\", {\n rating: sessionRating,\n highlights: \"Quick session rating\",\n improvements: \"\",\n });\n } finally {\n isHookCall = false;\n }\n }\n }\n\n // Aggregate session into user model (v0.27)\n if (turnCount >= 2 && sessionMinutes >= 1) {\n try {\n const snapshot: SessionSnapshot = {\n sessionId,\n date: new Date().toISOString().split(\"T\")[0],\n durationMinutes: sessionMinutes,\n turnCount,\n dominantSentiment: finalState.sentiment.dominant,\n avgFrustration: finalState.sentiment.frustration,\n avgExcitement: finalState.sentiment.excitement,\n avgConfusion: finalState.sentiment.confusion,\n avgFatigue: finalState.sentiment.fatigue,\n toolCalls: observationSession?.stats.toolCalls ?? 0,\n toolErrors: observationSession?.stats.toolErrors ?? 0,\n blockers: observationSession?.stats.blockers ?? 0,\n milestones: observationSession?.stats.milestones ?? 0,\n topicShifts: observationSession?.stats.topicShifts ?? 0,\n peakEnergy: finalState.energy,\n primaryMode: finalState.activeMode,\n timePeriod: period,\n rating: sessionRating,\n hadPostmortem: false, // updated below if postmortem is generated\n wellbeingNudges: finalState.wellbeingNudge ? [finalState.wellbeingNudge] : [],\n };\n\n const model = (await loadUserModel()) ?? createEmptyModel();\n const updated = aggregateSession(model, snapshot);\n await saveUserModel(updated);\n log.debug(\"hooks\", `User model updated (session ${updated.profile.totalSessions})`);\n\n // Sync model metrics to acore dynamics section\n if (ctx.config.personalityAdapt !== false) {\n try {\n isHookCall = true;\n await syncPersonalityToCore(finalState, ctx.mcpManager, {\n trustScore: updated.profile.trustScore,\n totalSessions: updated.profile.totalSessions,\n sentimentTrend: updated.profile.sentimentTrend,\n });\n } finally {\n isHookCall = false;\n }\n }\n } catch (err) {\n log.debug(\"hooks\", \"user model aggregation failed\", err);\n }\n }\n\n // Auto post-mortem (smart trigger)\n if (\n ctx.config.autoPostmortem !== false &&\n observationSession &&\n shouldAutoPostmortem(observationSession, messages)\n ) {\n try {\n const client = ctx.llmClient;\n if (client) {\n // Load rejected skill names for feedback loop\n const rejectionsPath = path.join(\n os.homedir(),\n \".aman-agent\",\n \"crystallization-rejections.json\",\n );\n const rejectedNames = await loadRejectedNames(rejectionsPath);\n\n const report = await generatePostmortemReport(\n sessionId,\n messages,\n observationSession,\n client,\n undefined,\n rejectedNames,\n );\n if (report) {\n const filePath = await savePostmortem(report);\n console.log(pc.dim(`\\n Post-mortem saved → ${filePath}`));\n\n // Store actionable patterns as memories\n for (const pattern of report.patterns) {\n try {\n await memoryStore({\n content: pattern,\n type: \"pattern\",\n tags: [\"postmortem\", \"auto\"],\n confidence: 0.7,\n });\n } catch {\n // Silent — don't block exit\n }\n }\n\n // Crystallization prompt loop (v0.26 + v0.28 reinforcement)\n if (\n report.crystallizationCandidates &&\n report.crystallizationCandidates.length > 0\n ) {\n const skillsMdPath = path.join(os.homedir(), \".askill\", \"skills.md\");\n const logPath = path.join(\n os.homedir(),\n \".aman-agent\",\n \"crystallization-log.json\",\n );\n const rejectionsPath2 = path.join(\n os.homedir(),\n \".aman-agent\",\n \"crystallization-rejections.json\",\n );\n const suggestionsPath = path.join(\n os.homedir(),\n \".aman-agent\",\n \"crystallization-suggestions.json\",\n );\n const postmortemFilename = `${report.date}-${report.sessionId.slice(0, 4)}.md`;\n\n console.log(\n pc.dim(`\\n Crystallization candidates: ${report.crystallizationCandidates.length}`),\n );\n\n let skipAll = false;\n for (const rawCandidate of report.crystallizationCandidates) {\n if (skipAll) break;\n const candidate = validateCandidate(rawCandidate);\n if (!candidate) {\n log.debug(\"hooks\", \"candidate failed validation\");\n continue;\n }\n\n // Track suggestion count for reinforcement\n const suggestCount = await incrementSuggestionCount(candidate.name, suggestionsPath);\n const reinforced = suggestCount >= 3;\n\n const message = reinforced\n ? `Crystallize \"${candidate.name}\"? (suggested ${suggestCount}× across sessions — high confidence)`\n : `Crystallize \"${candidate.name}\" as a reusable skill?`;\n\n const choice = await p.select({\n message,\n options: [\n { value: \"accept\", label: reinforced ? \"Yes — recommended (seen multiple times)\" : \"Yes — write to ~/.askill/skills.md\" },\n { value: \"reject\", label: \"No — skip this one\" },\n { value: \"skip-all\", label: \"Skip all crystallization for this session\" },\n ],\n initialValue: reinforced ? \"accept\" : \"reject\",\n });\n\n if (p.isCancel(choice) || choice === \"skip-all\") {\n skipAll = true;\n break;\n }\n\n if (choice === \"accept\") {\n const result = await writeSkillToFile(\n candidate,\n skillsMdPath,\n postmortemFilename,\n );\n if (result.written) {\n console.log(\n pc.green(` ✓ Crystallized: ${candidate.name} → ${result.filePath}`),\n );\n console.log(pc.dim(` Triggers: ${candidate.triggers.join(\", \")}`));\n console.log(pc.dim(` Will auto-activate next session.`));\n await appendCrystallizationLog(\n {\n name: candidate.name,\n createdAt: new Date().toISOString(),\n fromPostmortem: postmortemFilename,\n confidence: candidate.confidence,\n triggers: candidate.triggers,\n },\n logPath,\n );\n } else if (result.collidesWith) {\n // Collision detected — offer merge\n const mergeChoice = await p.select({\n message: `\"${candidate.name}\" collides with existing \"${result.collidesWith}\". Merge?`,\n options: [\n { value: \"merge\", label: `Yes — replace \"${result.collidesWith}\" with updated version` },\n { value: \"skip\", label: \"No — keep existing\" },\n ],\n initialValue: \"merge\",\n });\n\n if (!p.isCancel(mergeChoice) && mergeChoice === \"merge\") {\n const mergeResult = await mergeSkillInFile(\n candidate,\n result.collidesWith,\n skillsMdPath,\n postmortemFilename,\n );\n if (mergeResult.written) {\n console.log(pc.green(` ✓ Merged: ${candidate.name} (replaced \"${result.collidesWith}\")`));\n await appendCrystallizationLog(\n {\n name: candidate.name,\n createdAt: new Date().toISOString(),\n fromPostmortem: postmortemFilename,\n confidence: candidate.confidence,\n triggers: candidate.triggers,\n },\n logPath,\n );\n } else {\n console.log(pc.yellow(` ⊘ Merge failed: ${mergeResult.reason}`));\n }\n } else {\n console.log(pc.dim(` Kept existing: ${result.collidesWith}`));\n }\n } else {\n console.log(pc.yellow(` ⊘ Could not crystallize: ${result.reason}`));\n }\n } else {\n console.log(pc.dim(` Skipped: ${candidate.name}`));\n await appendRejection(candidate, postmortemFilename, rejectionsPath2);\n }\n }\n }\n }\n }\n } catch (err) {\n log.debug(\"hooks\", \"auto post-mortem failed\", err);\n }\n }\n } catch (err) {\n log.warn(\"hooks\", \"session end hook failed\", err);\n }\n}\n","import type { McpManager } from \"./mcp/client.js\";\nimport type { UserProfile } from \"./user-model.js\";\nimport { log } from \"./logger.js\";\n\nexport interface PersonalityState {\n currentRead: string;\n energy: \"high-drive\" | \"steady\" | \"reflective\";\n activeMode: \"Default\" | \"Focused Work\" | \"Creative\" | \"Personal\";\n sleepReminder: boolean;\n wellbeingNudge: string | null;\n sentiment: SentimentRead;\n}\n\nexport interface SentimentRead {\n frustration: number; // 0-1\n excitement: number; // 0-1\n confusion: number; // 0-1\n fatigue: number; // 0-1\n dominant: \"neutral\" | \"frustrated\" | \"excited\" | \"confused\" | \"fatigued\";\n}\n\nexport interface PersonalitySignals {\n timePeriod: string;\n sessionMinutes: number;\n turnCount: number;\n recentMessages?: string[]; // last N user messages for sentiment analysis\n}\n\n// --- Sentiment Detection (keyword-based, zero latency) ---\n\nconst FRUSTRATION_SIGNALS = [\n /\\b(ugh|argh|damn|dammit|wtf|ffs|shit|fuck|crap|hate this|stupid|broken|still not|doesn't work|not working|won't work|keeps failing|again\\?!|what the hell|for the love of|give up|giving up|fed up)\\b/i,\n /\\b(why (is|does|won't|can't|isn't)|same (error|issue|problem|bug)|tried everything|nothing works|no idea|lost|stuck|frustrated|annoying|impossible)\\b/i,\n /!{2,}/, // multiple exclamation marks\n /\\?{2,}/, // multiple question marks (exasperation)\n];\n\nconst EXCITEMENT_SIGNALS = [\n /\\b(amazing|awesome|perfect|brilliant|love it|yes!|nice!|great!|finally|it works|nailed it|beautiful|incredible|exactly|that's it|hell yeah|wow|woah|let's go)\\b/i,\n /\\b(excited|pumped|stoked|can't wait|this is great|so cool|love this)\\b/i,\n /!{1,}.*(!|🎉|🚀|✨|💪|🔥)/,\n];\n\nconst CONFUSION_SIGNALS = [\n /\\b(confused|don't understand|what do you mean|huh\\??|makes no sense|i'm lost|unclear|what\\?|how does that|wait what|can you explain|i don't get)\\b/i,\n /\\b(which one|what's the difference|should i|not sure (if|what|how|why|whether))\\b/i,\n];\n\nconst FATIGUE_SIGNALS = [\n /\\b(tired|exhausted|long day|need (a )?break|calling it|wrapping up|done for (now|today)|heading (to bed|off)|good night|gn|signing off|one more thing then|last one)\\b/i,\n /\\b(brain (is )?fried|can't think|eyes (are )?heavy|running on fumes|barely awake)\\b/i,\n];\n\nfunction scorePatterns(text: string, patterns: RegExp[]): number {\n let hits = 0;\n for (const p of patterns) {\n if (p.test(text)) hits++;\n }\n return Math.min(hits / patterns.length, 1);\n}\n\n/**\n * Detect sentiment from recent user messages.\n * Lightweight keyword-based analysis — no LLM calls.\n */\nexport function detectSentiment(recentMessages: string[]): SentimentRead {\n if (recentMessages.length === 0) {\n return { frustration: 0, excitement: 0, confusion: 0, fatigue: 0, dominant: \"neutral\" };\n }\n\n // Weight recent messages more heavily (last message = 1.0, second-last = 0.6, third = 0.3)\n const weights = [1.0, 0.6, 0.3, 0.2, 0.1];\n let frustration = 0, excitement = 0, confusion = 0, fatigue = 0;\n let totalWeight = 0;\n\n for (let i = 0; i < Math.min(recentMessages.length, weights.length); i++) {\n const msg = recentMessages[recentMessages.length - 1 - i];\n const w = weights[i];\n totalWeight += w;\n\n frustration += scorePatterns(msg, FRUSTRATION_SIGNALS) * w;\n excitement += scorePatterns(msg, EXCITEMENT_SIGNALS) * w;\n confusion += scorePatterns(msg, CONFUSION_SIGNALS) * w;\n fatigue += scorePatterns(msg, FATIGUE_SIGNALS) * w;\n }\n\n if (totalWeight > 0) {\n frustration /= totalWeight;\n excitement /= totalWeight;\n confusion /= totalWeight;\n fatigue /= totalWeight;\n }\n\n // Determine dominant sentiment\n const scores = { frustrated: frustration, excited: excitement, confused: confusion, fatigued: fatigue };\n const maxKey = Object.entries(scores).reduce((a, b) => a[1] > b[1] ? a : b);\n const dominant = maxKey[1] > 0.15 ? maxKey[0] as SentimentRead[\"dominant\"] : \"neutral\";\n\n return { frustration, excitement, confusion, fatigue, dominant };\n}\n\n// --- Personality Computation ---\n\n/**\n * Compute personality state from current signals including sentiment.\n * Pure function — no side effects.\n */\nexport function computePersonality(signals: PersonalitySignals): PersonalityState {\n const { timePeriod, sessionMinutes, turnCount, recentMessages } = signals;\n\n // Detect sentiment from recent messages\n const sentiment = detectSentiment(recentMessages || []);\n\n // Energy curve: time + session + sentiment\n let energy: PersonalityState[\"energy\"] = \"steady\";\n if (timePeriod === \"morning\" && sentiment.dominant !== \"fatigued\") {\n energy = \"high-drive\";\n } else if (timePeriod === \"late-night\" || (timePeriod === \"night\" && sessionMinutes > 45)) {\n energy = \"reflective\";\n } else if (sentiment.dominant === \"fatigued\") {\n energy = \"reflective\";\n } else if (sentiment.dominant === \"excited\") {\n energy = \"high-drive\"; // match their energy\n } else if (timePeriod === \"afternoon\" && turnCount > 20) {\n energy = \"reflective\";\n }\n\n // Active mode: time + sentiment\n let activeMode: PersonalityState[\"activeMode\"] = \"Default\";\n if (timePeriod === \"late-night\") {\n activeMode = \"Personal\";\n } else if (sentiment.dominant === \"frustrated\" || sentiment.dominant === \"fatigued\") {\n activeMode = \"Personal\"; // warm, patient when they're struggling\n }\n\n // Current read: combines time context + sentiment\n const readParts: string[] = [];\n\n // Time-based read\n switch (timePeriod) {\n case \"late-night\":\n readParts.push(\"late night session\");\n if (sessionMinutes > 60) readParts.push(\"been going a while\");\n else readParts.push(\"quiet hours\");\n break;\n case \"morning\":\n readParts.push(\"morning session\");\n if (turnCount <= 3) readParts.push(\"just getting started\");\n else readParts.push(\"building momentum\");\n break;\n case \"afternoon\":\n readParts.push(\"afternoon session\");\n if (turnCount > 15) readParts.push(\"deep in flow\");\n else readParts.push(\"steady pace\");\n break;\n case \"evening\":\n readParts.push(\"evening session\");\n if (sessionMinutes > 60) readParts.push(\"long session\");\n break;\n case \"night\":\n readParts.push(\"night session\");\n if (sessionMinutes > 45) readParts.push(\"getting late\");\n break;\n }\n\n // Sentiment-based read\n switch (sentiment.dominant) {\n case \"frustrated\":\n readParts.push(\"user seems stuck or frustrated\");\n break;\n case \"excited\":\n readParts.push(\"user is energized and making progress\");\n break;\n case \"confused\":\n readParts.push(\"user may need clearer explanations\");\n break;\n case \"fatigued\":\n readParts.push(\"user seems tired\");\n break;\n }\n\n const currentRead = readParts.join(\", \");\n\n // Sleep guardian\n const sleepReminder =\n (timePeriod === \"late-night\" && sessionMinutes > 60) ||\n (timePeriod === \"night\" && sessionMinutes > 90);\n\n // Wellbeing nudges (beyond sleep)\n let wellbeingNudge: string | null = null;\n\n if (sleepReminder && sentiment.dominant === \"frustrated\") {\n wellbeingNudge = \"sleep-frustrated\";\n } else if (sleepReminder) {\n wellbeingNudge = \"sleep\";\n } else if (sentiment.dominant === \"frustrated\" && sessionMinutes > 90) {\n wellbeingNudge = \"break-frustrated\";\n } else if (sentiment.dominant === \"frustrated\" && turnCount > 15) {\n wellbeingNudge = \"step-back\";\n } else if (sentiment.dominant === \"fatigued\") {\n wellbeingNudge = \"rest\";\n } else if (sessionMinutes > 120) {\n wellbeingNudge = \"break-long-session\";\n }\n\n return { currentRead, energy, activeMode, sleepReminder, wellbeingNudge, sentiment };\n}\n\n// --- Wellbeing Nudge Formatting ---\n\nconst WELLBEING_NUDGES: Record<string, string> = {\n \"sleep\": `<wellbeing>\nIt's late and this session has been running a while. When there's a natural pause, gently mention they might want to wrap up soon. One brief mention is enough — don't be pushy.\n</wellbeing>`,\n\n \"sleep-frustrated\": `<wellbeing>\nIt's late, the session has been long, and the user seems frustrated. This is a tough combination. Acknowledge what they're dealing with is hard, suggest they sleep on it — fresh eyes in the morning often solve what hours of late-night debugging can't. Be warm, not condescending.\n</wellbeing>`,\n\n \"break-frustrated\": `<wellbeing>\nThe user has been at this for over 90 minutes and seems frustrated. If the conversation allows, gently suggest stepping away for a few minutes — a short break often unblocks what persistence can't. Frame it as a strategy, not giving up.\n</wellbeing>`,\n\n \"step-back\": `<wellbeing>\nThe user seems stuck or frustrated. Consider: offer to re-approach the problem from a different angle, break it into smaller pieces, or explain the underlying concept. Match their directness — don't over-soothe, just help them find a way forward.\n</wellbeing>`,\n\n \"rest\": `<wellbeing>\nThe user seems tired. Keep responses concise and to the point. If they mention wrapping up, support that. Don't add extra complexity or tangents.\n</wellbeing>`,\n\n \"break-long-session\": `<wellbeing>\nThis session has been running for over 2 hours. If there's a natural moment, a brief mention that a short break might help maintain focus is fine. Once is enough.\n</wellbeing>`,\n\n \"burnout-warning\": `<wellbeing>\nRecent patterns suggest the user may be approaching burnout — rising frustration, declining satisfaction, long or late sessions. Be extra mindful: keep responses concise, celebrate small wins, gently suggest breaks or scope reduction. Don't mention burnout directly unless they bring it up.\n</wellbeing>`,\n};\n\n/**\n * Format the appropriate wellbeing nudge for the current state.\n */\nexport function formatWellbeingNudge(state: PersonalityState): string | null {\n if (!state.wellbeingNudge) return null;\n return WELLBEING_NUDGES[state.wellbeingNudge] || null;\n}\n\n/**\n * Check whether a nudge should fire based on user model nudge stats.\n * If a nudge type has been fired 5+ times with avg rating below 0.4 (on 0-1 scale),\n * suppress it (return false). Otherwise allow (return true).\n * If no profile provided, always allows.\n */\nexport function shouldFireNudge(nudgeType: string, profile?: UserProfile): boolean {\n if (!profile) return true;\n\n const stats = profile.nudgeStats[nudgeType];\n if (!stats || stats.fired < 5) return true;\n\n // sessionRatingAfter is avg on 0-1 scale (0=frustrating, 1=great)\n // If avg rating after nudge is < 0.4, this nudge is hurting more than helping\n if (stats.sessionRatingAfter < 0.4) {\n log.debug(\"personality\", `suppressing nudge \"${nudgeType}\" — low avg rating ${stats.sessionRatingAfter.toFixed(2)} after ${stats.fired} fires`);\n return false;\n }\n\n return true;\n}\n\n/**\n * Push current personality state to acore via identity_update_dynamics.\n * Optionally includes user model metrics (trust, sessions, sentiment trend).\n * Fire-and-forget — failures are logged but don't block.\n */\nexport async function syncPersonalityToCore(\n state: PersonalityState,\n mcpManager: McpManager,\n modelMetrics?: { trustScore: number; totalSessions: number; sentimentTrend: string },\n): Promise<void> {\n try {\n const payload: Record<string, unknown> = {\n currentRead: state.currentRead,\n energy: state.energy,\n activeMode: state.activeMode,\n };\n if (modelMetrics) {\n payload.trust = `${(modelMetrics.trustScore * 100).toFixed(0)}%`;\n payload.sessions = modelMetrics.totalSessions;\n payload.sentimentTrend = modelMetrics.sentimentTrend;\n }\n await mcpManager.callTool(\"identity_update_dynamics\", payload);\n } catch (err) {\n log.debug(\"personality\", \"identity_update_dynamics failed\", err);\n }\n}\n","import {\n createDatabase,\n recall,\n buildContext,\n storeMemory,\n consolidateMemories,\n cosineSimilarity,\n preloadEmbeddings,\n buildVectorIndex,\n recallMemories,\n generateEmbedding,\n getVectorIndex,\n runDiagnostics,\n repairDatabase,\n loadConfig,\n saveConfig,\n multiStrategyRecall,\n reflect,\n isReflectionDue,\n syncFromClaude,\n exportForTeam,\n importFromTeam,\n syncToCopilot,\n type AmemDatabase,\n type RecallResult,\n type ContextResult,\n type StoreResult,\n type StoreOptions,\n type ConsolidationReport,\n type MemoryStats,\n type Memory,\n type MemoryVersion,\n type MemoryRelation,\n type DiagnosticReport,\n type ReflectionReport,\n type ReflectionConfig,\n type AmemConfig,\n type SyncResult,\n type TeamExportOptions,\n type TeamImportOptions,\n type TeamImportResult,\n type CopilotSyncOptions,\n type CopilotSyncResult,\n} from \"@aman_asmuei/amem-core\";\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport fs from \"node:fs\";\n\nlet db: AmemDatabase | null = null;\nlet currentProject = \"global\";\n\nexport async function initMemory(project?: string): Promise<AmemDatabase> {\n if (db) return db;\n\n const amemDir = process.env.AMEM_DIR ?? path.join(os.homedir(), \".amem\");\n if (!fs.existsSync(amemDir)) fs.mkdirSync(amemDir, { recursive: true });\n\n const dbPath = process.env.AMEM_DB ?? path.join(amemDir, \"memory.db\");\n\n try {\n db = createDatabase(dbPath);\n } catch (err) {\n // Attempt recovery: if DB is corrupted, back it up and create fresh\n const backupPath = `${dbPath}.corrupt.${Date.now()}`;\n try {\n if (fs.existsSync(dbPath)) {\n fs.renameSync(dbPath, backupPath);\n // Remove WAL/SHM files too\n if (fs.existsSync(`${dbPath}-wal`)) fs.unlinkSync(`${dbPath}-wal`);\n if (fs.existsSync(`${dbPath}-shm`)) fs.unlinkSync(`${dbPath}-shm`);\n console.error(`[amem] Database corrupted — backed up to ${backupPath}`);\n console.error(\"[amem] Creating fresh database. Previous memories are in the backup file.\");\n db = createDatabase(dbPath);\n } else {\n throw err;\n }\n } catch {\n console.error(`[amem] Failed to initialize memory: ${err instanceof Error ? err.message : String(err)}`);\n console.error(`[amem] Try deleting ${amemDir} to reset: rm -rf ${amemDir}`);\n throw err;\n }\n }\n\n currentProject = project ?? \"global\";\n\n preloadEmbeddings();\n\n setTimeout(() => {\n try { buildVectorIndex(db!); } catch {}\n }, 1000);\n\n return db;\n}\n\nexport function getDb(): AmemDatabase {\n if (!db) throw new Error(\"Memory not initialized — call initMemory() first\");\n return db;\n}\n\nexport function getProject(): string {\n return currentProject;\n}\n\nexport async function memoryRecall(query: string, opts?: {\n limit?: number;\n compact?: boolean;\n type?: string;\n tag?: string;\n minConfidence?: number;\n explain?: boolean;\n}): Promise<RecallResult> {\n return recall(getDb(), {\n query,\n limit: opts?.limit ?? 10,\n compact: opts?.compact ?? true,\n type: opts?.type,\n tag: opts?.tag,\n minConfidence: opts?.minConfidence,\n explain: opts?.explain,\n scope: currentProject,\n });\n}\n\nexport async function memoryContext(topic: string, maxTokens?: number): Promise<ContextResult> {\n return buildContext(getDb(), topic, { maxTokens, scope: currentProject });\n}\n\nexport async function memoryStore(opts: StoreOptions): Promise<StoreResult> {\n return storeMemory(getDb(), opts);\n}\n\nexport function memoryLog(sessionId: string, role: string, content: string): string {\n return getDb().appendLog({\n sessionId,\n role: role as \"user\" | \"assistant\" | \"system\",\n content,\n project: currentProject,\n metadata: {},\n });\n}\n\nexport function reminderCheck(): Array<{ id: string; content: string; dueAt: number | null; status: \"overdue\" | \"today\" | \"upcoming\"; scope: string }> {\n const all = getDb().checkReminders();\n return all.filter((r) => r.scope === \"global\" || r.scope === currentProject);\n}\n\nexport async function memoryForget(opts: { id?: string; query?: string; type?: string }): Promise<{ deleted: number; message: string }> {\n const db = getDb();\n if (opts.id) {\n const fullId = db.resolveId(opts.id) ?? opts.id;\n const memory = db.getById(fullId);\n if (!memory) return { deleted: 0, message: `Memory ${opts.id} not found.` };\n db.deleteMemory(fullId);\n const vecIdx = getVectorIndex();\n if (vecIdx) vecIdx.remove(fullId);\n return { deleted: 1, message: `Deleted: \"${memory.content}\" (${memory.type})` };\n }\n // Type-based delete: delete all memories of a given type\n if (opts.type) {\n const all = db.getAllForProject(currentProject);\n const matches = all.filter((m) => m.type === opts.type);\n if (matches.length === 0) return { deleted: 0, message: `No memories of type \"${opts.type}\" found.` };\n const vecIdx = getVectorIndex();\n for (const m of matches) {\n db.deleteMemory(m.id);\n if (vecIdx) vecIdx.remove(m.id);\n }\n return { deleted: matches.length, message: `Deleted ${matches.length} \"${opts.type}\" memories.` };\n }\n if (opts.query) {\n const queryEmbedding = await generateEmbedding(opts.query);\n const matches = recallMemories(db, { query: opts.query, queryEmbedding, limit: 50, minConfidence: 0, scope: currentProject });\n if (matches.length === 0) return { deleted: 0, message: `No memories found matching \"${opts.query}\".` };\n const vecIdx = getVectorIndex();\n for (const m of matches) {\n db.deleteMemory(m.id);\n if (vecIdx) vecIdx.remove(m.id);\n }\n return { deleted: matches.length, message: `Deleted ${matches.length} memories matching \"${opts.query}\".` };\n }\n return { deleted: 0, message: \"Provide an id, type, or query to forget.\" };\n}\n\nlet _localMemoryConfig: { maxStaleDays?: number; minConfidence?: number; minAccessCount?: number; maxRecallTokens?: number } = {};\n\nexport function setMemoryConfig(config: typeof _localMemoryConfig): void {\n _localMemoryConfig = config;\n}\n\nexport function getMaxRecallTokens(): number {\n return _localMemoryConfig.maxRecallTokens ?? 1500;\n}\n\nexport function memoryConsolidate(dryRun = false): ConsolidationReport {\n return consolidateMemories(getDb(), cosineSimilarity, {\n dryRun,\n maxStaleDays: _localMemoryConfig.maxStaleDays ?? 90,\n minConfidence: _localMemoryConfig.minConfidence ?? 0.3,\n minAccessCount: _localMemoryConfig.minAccessCount ?? 0,\n });\n}\n\nexport function isMemoryInitialized(): boolean {\n return db !== null;\n}\n\nexport function memoryStats(): MemoryStats {\n return getDb().getStats();\n}\n\nexport function memoryExport(): Memory[] {\n return getDb().getAllForProject(currentProject);\n}\n\nexport function memorySince(hours: number): Memory[] {\n const since = Date.now() - hours * 60 * 60 * 1000;\n const all = getDb().getMemoriesSince(since);\n return all.filter((m) => m.scope === \"global\" || m.scope === currentProject);\n}\n\nexport function memorySearch(query: string, limit?: number): Memory[] {\n return getDb().fullTextSearch(query, limit, currentProject);\n}\n\nexport function reminderSet(content: string, dueAt?: number): string {\n return getDb().insertReminder(content, dueAt ?? null, currentProject);\n}\n\nexport function reminderList(includeCompleted?: boolean): Array<{ id: string; content: string; dueAt: number | null; completed: boolean; createdAt: number; scope: string }> {\n return getDb().listReminders(includeCompleted, currentProject);\n}\n\nexport function reminderComplete(id: string): boolean {\n const fullId = getDb().resolveReminderId(id) ?? id;\n return getDb().completeReminder(fullId);\n}\n\nexport { type RecallResult, type ContextResult, type StoreResult, type StoreOptions, type ConsolidationReport, type MemoryStats, type Memory };\n\n// ─── Admin: Doctor ───────────────────────────────────────────────────────────\n\n/**\n * Run read-only health diagnostics on the amem database.\n * Returns a structured report with status, stats, and a list of issues.\n */\nexport async function memoryDoctor(): Promise<DiagnosticReport> {\n return runDiagnostics(getDb());\n}\n\n// ─── Admin: Repair ───────────────────────────────────────────────────────────\n\nexport interface MemoryRepairResult {\n dryRun: boolean;\n status: \"healthy\" | \"warning\" | \"critical\";\n issues: string[];\n actions: string[];\n}\n\n/**\n * Diagnose and optionally repair the memory database.\n * By default runs in dry-run mode — set dryRun:false to apply fixes.\n */\nexport async function memoryRepair(\n opts: { dryRun?: boolean } = {}\n): Promise<MemoryRepairResult> {\n const dryRun = opts.dryRun ?? true;\n if (dryRun) {\n // Dry-run: run diagnostics and surface what would be repaired\n const diag = runDiagnostics(getDb());\n return {\n dryRun: true,\n status: diag.status,\n issues: diag.issues.map((issue) => issue.message),\n actions: diag.issues.map((issue) => `Would fix: ${issue.suggestion}`),\n };\n }\n // Actual repair: call repairDatabase with the DB path\n const dbPath = process.env.AMEM_DB ?? path.join(os.homedir(), \".amem\", \"memory.db\");\n const result = repairDatabase(dbPath);\n return {\n dryRun: false,\n status: result.status === \"repaired\" ? \"warning\" : result.status === \"failed\" ? \"critical\" : \"healthy\",\n issues: [],\n actions: result.memoriesRecovered > 0 ? [`Recovered ${result.memoriesRecovered} memories`] : [],\n };\n}\n\n// ─── Admin: Config ───────────────────────────────────────────────────────────\n\n/**\n * Read or update the amem configuration.\n * With no args, returns the current config.\n * With updates, deep-merges and saves the new config, then returns authoritative post-save state.\n */\nexport async function memoryConfig(\n updates?: DeepPartial<AmemConfig>\n): Promise<AmemConfig> {\n const current = loadConfig();\n if (updates && Object.keys(updates).length > 0) {\n saveConfig(updates as Partial<AmemConfig>);\n return loadConfig(); // read back authoritative merged state\n }\n return current;\n}\n\n// ─── Advanced Recall ─────────────────────────────────────────────────────────\n\n/**\n * Multi-strategy recall: combines semantic, FTS5, knowledge graph, and\n * temporal scoring into a unified ranked result.\n */\nexport async function memoryMultiRecall(\n query: string,\n opts: { limit?: number; scope?: string } = {}\n): Promise<{ memories: Awaited<ReturnType<typeof multiStrategyRecall>>; total: number }> {\n const queryEmbedding = await generateEmbedding(query);\n const memories = await multiStrategyRecall(getDb(), {\n query,\n queryEmbedding,\n limit: opts.limit ?? 10,\n scope: opts.scope ?? currentProject ?? undefined,\n });\n return { memories, total: memories.length };\n}\n\n// ─── Reflection ──────────────────────────────────────────────────────────────\n\n/**\n * Run the self-evolving memory reflection engine.\n * Returns clusters, contradictions, and synthesis candidates.\n */\nexport async function memoryReflect(\n config?: Partial<ReflectionConfig>\n): Promise<ReflectionReport> {\n return reflect(getDb(), config);\n}\n\n/**\n * Check whether a reflection run is due based on last-run metadata.\n */\nexport function checkReflectionDue(): { due: boolean; reason: string } {\n return isReflectionDue(getDb());\n}\n\n// ─── Tier ────────────────────────────────────────────────────────────────────\n\n/**\n * Move a memory between tiers: \"core\" | \"working\" | \"archival\".\n */\nexport function memoryTier(\n id: string,\n tier: string,\n): { id: string; tier: string; ok: true } | { ok: false; error: string } {\n try {\n const db = getDb();\n const fullId = db.resolveId(id) ?? id;\n db.updateTier(fullId, tier);\n return { id: fullId, tier, ok: true };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\n// ─── Detail ──────────────────────────────────────────────────────────────────\n\n/**\n * Get the full Memory object for a given id. Returns null if not found.\n */\nexport function memoryDetail(id: string): Memory | null {\n const db = getDb();\n const fullId = db.resolveId(id) ?? id;\n return db.getById(fullId);\n}\n\n// ─── Relate ──────────────────────────────────────────────────────────────────\n\n/**\n * Add a knowledge-graph relation between two memories.\n */\nexport function memoryRelate(\n fromId: string,\n toId: string,\n type: string,\n strength?: number,\n): { ok: true; relationId: string } | { ok: false; error: string } {\n try {\n const relationId = getDb().addRelation(fromId, toId, type, strength);\n return { ok: true, relationId };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\n// ─── Expire ──────────────────────────────────────────────────────────────────\n\n/**\n * Mark a memory as expired (sets valid_until to now).\n * The optional `reason` string is for caller-side logging only — the db\n * itself stores the timestamp rather than a reason field.\n */\nexport function memoryExpire(\n id: string,\n reason?: string,\n): { ok: true; id: string; reason?: string } | { ok: false; error: string } {\n try {\n const db = getDb();\n const fullId = db.resolveId(id) ?? id;\n db.expireMemory(fullId);\n return { ok: true, id: fullId, ...(reason !== undefined ? { reason } : {}) };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\n// ─── Versions ────────────────────────────────────────────────────────────────\n\n/**\n * Return the full version history for a memory.\n */\nexport function memoryVersions(id: string): MemoryVersion[] {\n const db = getDb();\n const fullId = db.resolveId(id) ?? id;\n return db.getVersionHistory(fullId);\n}\n\n// ─── Sync ────────────────────────────────────────────────────────────────────\n\nexport type MemorySyncAction = \"import-claude\" | \"export-team\" | \"import-team\" | \"sync-copilot\";\n\nexport interface MemorySyncOptions {\n /** For import-claude: filter to a specific project path */\n projectFilter?: string;\n /** For import-claude / export-team: skip actual writes */\n dryRun?: boolean;\n /** For export-team: output directory */\n outputDir?: string;\n /** For export-team: userId identifier in the export manifest */\n userId?: string;\n /** For import-team: path to the JSON export file */\n filePath?: string;\n /** For sync-copilot: options forwarded to syncToCopilot */\n copilotOptions?: CopilotSyncOptions;\n /** For import-team: options forwarded to importFromTeam */\n importOptions?: TeamImportOptions;\n}\n\n/**\n * Sync memories with Claude Code auto-memory or team members.\n *\n * Actions:\n * - \"import-claude\" — read Claude Code memory files and import into amem\n * - \"export-team\" — write a shareable JSON export for teammates\n * - \"import-team\" — merge a teammate's JSON export into amem\n * - \"sync-copilot\" — update the Copilot instructions file from amem memories\n */\nexport async function memorySync(\n action: MemorySyncAction,\n opts: MemorySyncOptions = {},\n): Promise<SyncResult | TeamImportResult | { file: string; count: number } | CopilotSyncResult | { ok: false; error: string }> {\n const db = getDb();\n try {\n switch (action) {\n case \"import-claude\":\n return await syncFromClaude(db, opts.projectFilter, opts.dryRun ?? false);\n\n case \"export-team\": {\n const exportOptions: TeamExportOptions = {\n userId: opts.userId ?? currentProject,\n };\n return await exportForTeam(db, opts.outputDir ?? process.cwd(), exportOptions);\n }\n\n case \"import-team\":\n if (!opts.filePath) {\n return { ok: false, error: \"filePath is required for import-team\" };\n }\n return await importFromTeam(db, opts.filePath, opts.importOptions);\n\n case \"sync-copilot\":\n return syncToCopilot(db, opts.copilotOptions);\n\n default:\n return { ok: false, error: `Unknown sync action: ${action as string}` };\n }\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\nexport type { MemoryVersion, MemoryRelation, SyncResult, TeamImportResult, CopilotSyncResult };\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { readObservationEvents, type ObservationSession } from \"./observation.js\";\nimport type { LLMClient, Message } from \"./llm/types.js\";\nimport { log } from \"./logger.js\";\nimport type { SkillCandidate } from \"./crystallization.js\";\n\n// ── Types ──\n\nexport interface PostmortemReport {\n sessionId: string;\n date: string;\n duration: number;\n turnCount: number;\n summary: string;\n goals: string[];\n completed: string[];\n blockers: string[];\n decisions: string[];\n toolUsage: { name: string; count: number; errorRate: number }[];\n fileChanges: string[];\n topicProgression: string[];\n sentimentArc: string;\n patterns: string[];\n recommendations: string[];\n crystallizationCandidates?: SkillCandidate[];\n}\n\n// ── Default directories ──\n\nexport function defaultPostmortemsDir(): string {\n return path.join(os.homedir(), \".acore\", \"postmortems\");\n}\n\nfunction defaultObservationsDir(): string {\n return path.join(os.homedir(), \".acore\", \"observations\");\n}\n\n// ── Smart trigger ──\n\nexport function shouldAutoPostmortem(\n session: ObservationSession,\n messages: Message[],\n): boolean {\n if (messages.length < 6) return false;\n\n const durationMs = Date.now() - session.startedAt;\n return (\n session.stats.toolErrors >= 3 ||\n session.stats.blockers >= 2 ||\n durationMs > 60 * 60_000 ||\n hasAbandonedPlanSteps(messages) ||\n hasSustainedFrustration(session, 5)\n );\n}\n\nfunction hasAbandonedPlanSteps(messages: Message[]): boolean {\n // Check if any message mentions incomplete plan steps (e.g., /plan output with unchecked items)\n const text = messages\n .map((m) => (typeof m.content === \"string\" ? m.content : \"\"))\n .join(\"\\n\");\n const unchecked = (text.match(/- \\[ \\]/g) ?? []).length;\n const checked = (text.match(/- \\[x\\]/g) ?? []).length;\n // If there are plan steps and some are unchecked, the plan was abandoned\n return checked > 0 && unchecked > 0 && unchecked >= checked;\n}\n\nfunction hasSustainedFrustration(session: ObservationSession, threshold: number): boolean {\n // Use stats counter — events may have been flushed to disk\n return session.stats.blockers >= threshold;\n}\n\n// ── Helper: extract text from message content ──\n\nfunction messageContentToText(content: Message[\"content\"]): string {\n if (typeof content === \"string\") return content;\n return content\n .filter((b) => b.type === \"text\")\n .map((b) => (\"text\" in b ? b.text : \"\"))\n .join(\"\");\n}\n\n// ── Report generation ──\n\nconst POSTMORTEM_PROMPT = `Analyze this session and generate a structured post-mortem report.\nReturn ONLY valid JSON matching this schema (no markdown, no explanation):\n\n{\n \"summary\": \"2-3 sentence overview\",\n \"goals\": [\"what the user tried to accomplish\"],\n \"completed\": [\"what actually got done\"],\n \"blockers\": [\"what caused friction\"],\n \"decisions\": [\"key choices made with rationale\"],\n \"sentimentArc\": \"how mood evolved during session\",\n \"patterns\": [\"recurring behaviors worth remembering for future sessions\"],\n \"recommendations\": [\"actionable suggestions for next session\"],\n \"crystallizationCandidates\": [\n {\n \"name\": \"lowercase-kebab-name\",\n \"description\": \"1-sentence description of when this would be useful\",\n \"triggers\": [\"3-8\", \"trigger\", \"keywords\"],\n \"approach\": \"1-paragraph context: when and why to use this procedure\",\n \"steps\": [\"ordered step 1\", \"ordered step 2\"],\n \"gotchas\": [\"common mistake 1\"],\n \"confidence\": 0.0\n }\n ]\n}\n\nCRYSTALLIZATION RULES:\n- Only suggest 0-2 candidates per session — if nothing qualifies, return an empty array\n- Only suggest REUSABLE procedures (not one-off tasks specific to today's work)\n- The user must have demonstrated the procedure in this session\n- Confidence < 0.6 → don't suggest at all\n- Skip vague things like \"use library X\" — that's not procedural knowledge\n- Prefer narrow specific procedures over broad generalizations\n- Trigger keywords should be highly specific (avoid generic words like \"code\", \"fix\", \"the\")`;\n\nexport async function generatePostmortemReport(\n sessionId: string,\n messages: Message[],\n session: ObservationSession,\n client: LLMClient,\n obsDir?: string,\n rejectedSkillNames?: string[],\n): Promise<PostmortemReport | null> {\n try {\n const events = await readObservationEvents(sessionId, obsDir ?? defaultObservationsDir());\n\n // Compute tool usage from events\n const toolMap = new Map<string, { calls: number; errors: number }>();\n const fileChanges: string[] = [];\n const topicProgression: string[] = [];\n\n for (const event of events) {\n if (event.type === \"tool_call\") {\n const name = (event.data.tool as string) ?? \"unknown\";\n const entry = toolMap.get(name) ?? { calls: 0, errors: 0 };\n entry.calls++;\n toolMap.set(name, entry);\n } else if (event.type === \"tool_error\") {\n const name = (event.data.tool as string) ?? \"unknown\";\n const entry = toolMap.get(name) ?? { calls: 0, errors: 0 };\n entry.errors++;\n toolMap.set(name, entry);\n } else if (event.type === \"file_change\") {\n const p = (event.data.path as string) ?? \"unknown\";\n if (!fileChanges.includes(p)) fileChanges.push(p);\n } else if (event.type === \"topic_shift\") {\n const topics = (event.data.newTopics as string[]) ?? [];\n topicProgression.push(...topics);\n }\n }\n\n const toolUsage = [...toolMap.entries()].map(([name, { calls, errors }]) => ({\n name,\n count: calls,\n errorRate: calls > 0 ? Math.round((errors / calls) * 100) / 100 : 0,\n }));\n\n // Build LLM prompt with capped context\n const recentMessages = messages.slice(-20).map((m) => {\n const text = messageContentToText(m.content);\n return `${m.role}: ${text.slice(0, 200)}`;\n });\n const obsSnapshot = events.slice(-30).map((e) => `[${e.type}] ${e.summary}`);\n\n const durationMin = Math.round((Date.now() - session.startedAt) / 60_000);\n\n const prompt = `${POSTMORTEM_PROMPT}${\n rejectedSkillNames && rejectedSkillNames.length > 0\n ? `\\n\\nPREVIOUSLY REJECTED SKILLS (do NOT suggest these again):\\n${rejectedSkillNames.map((n) => `- ${n}`).join(\"\\n\")}`\n : \"\"\n }\n\nSession ID: ${sessionId}\nDuration: ${durationMin} minutes\nTurns: ${messages.length}\nTool calls: ${session.stats.toolCalls} (${session.stats.toolErrors} errors)\nBlockers: ${session.stats.blockers}\nMilestones: ${session.stats.milestones}\n\nRecent messages:\n${recentMessages.join(\"\\n\")}\n\nObservations:\n${obsSnapshot.join(\"\\n\")}`;\n\n const response = await client.chat(\n \"You are a session analyst. Output only valid JSON.\",\n [{ role: \"user\", content: prompt }],\n () => {}, // no-op onChunk — postmortem runs silently\n );\n\n const text = messageContentToText(response.message.content);\n const jsonMatch = text.match(/\\{[\\s\\S]*\\}/);\n if (!jsonMatch) {\n log.debug(\"postmortem\", \"LLM returned non-JSON response\");\n return null;\n }\n\n const parsed = JSON.parse(jsonMatch[0]);\n\n return {\n sessionId,\n date: new Date().toISOString().slice(0, 10),\n duration: durationMin,\n turnCount: messages.length,\n summary: parsed.summary ?? \"\",\n goals: parsed.goals ?? [],\n completed: parsed.completed ?? [],\n blockers: parsed.blockers ?? [],\n decisions: parsed.decisions ?? [],\n toolUsage,\n fileChanges,\n topicProgression: [...new Set(topicProgression)],\n sentimentArc: parsed.sentimentArc ?? \"\",\n patterns: parsed.patterns ?? [],\n recommendations: parsed.recommendations ?? [],\n crystallizationCandidates: Array.isArray(parsed.crystallizationCandidates)\n ? parsed.crystallizationCandidates\n : undefined,\n };\n } catch (err) {\n log.debug(\"postmortem\", \"Failed to generate post-mortem\", err);\n return null;\n }\n}\n\n// ── Markdown formatting ──\n\nexport function formatPostmortemMarkdown(report: PostmortemReport): string {\n const lines: string[] = [\n `# Post-Mortem: ${report.date}`,\n \"\",\n `**Session:** ${report.sessionId} | **Duration:** ${report.duration} min | **Turns:** ${report.turnCount}`,\n \"\",\n \"## Summary\",\n report.summary,\n \"\",\n ];\n\n if (report.goals.length > 0) {\n lines.push(\"## Goals\");\n report.goals.forEach((g) => lines.push(`- ${g}`));\n lines.push(\"\");\n }\n\n if (report.completed.length > 0) {\n lines.push(\"## Completed\");\n report.completed.forEach((c) => lines.push(`- [x] ${c}`));\n lines.push(\"\");\n }\n\n if (report.blockers.length > 0) {\n lines.push(\"## Blockers\");\n report.blockers.forEach((b) => lines.push(`- ${b}`));\n lines.push(\"\");\n }\n\n if (report.decisions.length > 0) {\n lines.push(\"## Decisions\");\n report.decisions.forEach((d) => lines.push(`- ${d}`));\n lines.push(\"\");\n }\n\n if (report.toolUsage.length > 0) {\n lines.push(\"## Tool Usage\");\n lines.push(\"| Tool | Calls | Error Rate |\");\n lines.push(\"|------|-------|------------|\");\n report.toolUsage.forEach((t) =>\n lines.push(`| ${t.name} | ${t.count} | ${Math.round(t.errorRate * 100)}% |`),\n );\n lines.push(\"\");\n }\n\n if (report.fileChanges.length > 0) {\n lines.push(\"## Files Changed\");\n report.fileChanges.forEach((f) => lines.push(`- \\`${f}\\``));\n lines.push(\"\");\n }\n\n if (report.topicProgression.length > 0) {\n lines.push(`## Topics`);\n lines.push(report.topicProgression.join(\" → \"));\n lines.push(\"\");\n }\n\n if (report.sentimentArc) {\n lines.push(\"## Sentiment Arc\");\n lines.push(report.sentimentArc);\n lines.push(\"\");\n }\n\n if (report.patterns.length > 0) {\n lines.push(\"## Patterns\");\n report.patterns.forEach((p) => lines.push(`- ${p}`));\n lines.push(\"\");\n }\n\n if (report.recommendations.length > 0) {\n lines.push(\"## Recommendations\");\n report.recommendations.forEach((r) => lines.push(`- ${r}`));\n lines.push(\"\");\n }\n\n if (\n report.crystallizationCandidates &&\n report.crystallizationCandidates.length > 0\n ) {\n lines.push(\"## Crystallization Candidates\");\n report.crystallizationCandidates.forEach((c) => {\n lines.push(`- **${c.name}** (confidence ${c.confidence})`);\n lines.push(` ${c.description}`);\n });\n lines.push(\"\");\n }\n\n return lines.join(\"\\n\");\n}\n\n// ── Save & read post-mortems ──\n\nexport async function savePostmortem(\n report: PostmortemReport,\n dir?: string,\n): Promise<string> {\n const pmDir = dir ?? defaultPostmortemsDir();\n await fs.mkdir(pmDir, { recursive: true });\n\n const shortId = report.sessionId.slice(0, 4);\n const fileName = `${report.date}-${shortId}.md`;\n const filePath = path.join(pmDir, fileName);\n\n const markdown = formatPostmortemMarkdown(report);\n await fs.writeFile(filePath, markdown, \"utf-8\");\n\n // Also write a JSON sidecar for lossless re-parsing\n const jsonPath = filePath.replace(/\\.md$/, \".json\");\n try {\n await fs.writeFile(jsonPath, JSON.stringify(report, null, 2), \"utf-8\");\n } catch (err) {\n log.debug(\"postmortem\", \"JSON sidecar write failed\", err);\n }\n\n return filePath;\n}\n\nexport async function listPostmortems(dir?: string): Promise<string[]> {\n const pmDir = dir ?? defaultPostmortemsDir();\n try {\n const files = await fs.readdir(pmDir);\n return files\n .filter((f) => f.endsWith(\".md\"))\n .sort()\n .reverse();\n } catch {\n return [];\n }\n}\n\nexport async function readPostmortem(\n name: string,\n dir?: string,\n): Promise<string | null> {\n const pmDir = dir ?? defaultPostmortemsDir();\n const fileName = name.endsWith(\".md\") ? name : `${name}.md`;\n try {\n return await fs.readFile(path.join(pmDir, fileName), \"utf-8\");\n } catch {\n return null;\n }\n}\n\nexport async function analyzePostmortemRange(\n sinceDays: number,\n client: LLMClient,\n dir?: string,\n): Promise<string | null> {\n const pmDir = dir ?? defaultPostmortemsDir();\n try {\n const files = await listPostmortems(pmDir);\n const cutoffDate = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000)\n .toISOString()\n .slice(0, 10);\n\n const recentFiles = files.filter((f) => f >= cutoffDate);\n if (recentFiles.length === 0) return \"No post-mortems found in the specified range.\";\n\n const contents: string[] = [];\n for (const f of recentFiles.slice(0, 10)) {\n const content = await readPostmortem(f, pmDir);\n if (content) contents.push(content);\n }\n\n const response = await client.chat(\n \"You are a session analyst. Analyze these post-mortems and identify trends.\",\n [\n {\n role: \"user\",\n content: `Analyze these ${contents.length} post-mortem reports from the last ${sinceDays} days. Identify:\n1. Recurring blockers\n2. Productivity patterns\n3. Tool reliability issues\n4. Topic continuity across sessions\n5. Actionable recommendations\n\nReports:\n${contents.join(\"\\n\\n---\\n\\n\")}`,\n },\n ],\n () => {}, // no-op onChunk\n );\n\n const text = messageContentToText(response.message.content);\n return text || null;\n } catch (err) {\n log.debug(\"postmortem\", \"Failed to analyze range\", err);\n return null;\n }\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\n\n// ── Types ──\n\nexport type ObservationEventType =\n | \"tool_call\"\n | \"tool_error\"\n | \"topic_shift\"\n | \"decision\"\n | \"blocker\"\n | \"milestone\"\n | \"file_change\"\n | \"sentiment_shift\"\n | \"error\"\n | \"phase_start\"\n | \"phase_complete\"\n | \"approval_gate\"\n | \"task_delegated\";\n\nexport interface ObservationEvent {\n timestamp: number;\n type: ObservationEventType;\n summary: string;\n data: Record<string, unknown>;\n}\n\nexport interface ObservationSession {\n sessionId: string;\n startedAt: number;\n events: ObservationEvent[];\n paused: boolean;\n stats: {\n toolCalls: number;\n toolErrors: number;\n topicShifts: number;\n blockers: number;\n milestones: number;\n fileChanges: number;\n };\n}\n\n// ── Stat counters by event type ──\n\nconst STAT_MAP: Partial<Record<ObservationEventType, keyof ObservationSession[\"stats\"]>> = {\n tool_call: \"toolCalls\",\n tool_error: \"toolErrors\",\n topic_shift: \"topicShifts\",\n blocker: \"blockers\",\n milestone: \"milestones\",\n file_change: \"fileChanges\",\n};\n\n// ── Default observations directory ──\n\nexport function defaultObservationsDir(): string {\n return path.join(os.homedir(), \".acore\", \"observations\");\n}\n\n// ── Core functions ──\n\nexport function createObservationSession(sessionId: string): ObservationSession {\n return {\n sessionId,\n startedAt: Date.now(),\n events: [],\n paused: false,\n stats: {\n toolCalls: 0,\n toolErrors: 0,\n topicShifts: 0,\n blockers: 0,\n milestones: 0,\n fileChanges: 0,\n },\n };\n}\n\nexport function recordEvent(\n session: ObservationSession,\n event: Omit<ObservationEvent, \"timestamp\">,\n): void {\n if (session.paused) return;\n\n const full: ObservationEvent = { ...event, timestamp: Date.now() };\n session.events.push(full);\n\n const statKey = STAT_MAP[event.type];\n if (statKey) {\n session.stats[statKey]++;\n }\n}\n\nexport function pauseObservation(session: ObservationSession): void {\n session.paused = true;\n}\n\nexport function resumeObservation(session: ObservationSession): void {\n session.paused = false;\n}\n\nexport async function flushEvents(\n session: ObservationSession,\n dir?: string,\n): Promise<void> {\n if (session.events.length === 0) return;\n\n const obsDir = dir ?? defaultObservationsDir();\n await fs.mkdir(obsDir, { recursive: true });\n\n const filePath = path.join(obsDir, `${session.sessionId}.jsonl`);\n const lines = session.events.map((e) => JSON.stringify(e)).join(\"\\n\") + \"\\n\";\n await fs.appendFile(filePath, lines, \"utf-8\");\n\n session.events.length = 0;\n}\n\nexport async function readObservationEvents(\n sessionId: string,\n dir?: string,\n): Promise<ObservationEvent[]> {\n const obsDir = dir ?? defaultObservationsDir();\n const filePath = path.join(obsDir, `${sessionId}.jsonl`);\n\n try {\n const content = await fs.readFile(filePath, \"utf-8\");\n return content\n .trim()\n .split(\"\\n\")\n .filter((line) => line.length > 0)\n .map((line) => JSON.parse(line) as ObservationEvent);\n } catch {\n return [];\n }\n}\n\nexport function getSessionStats(session: ObservationSession): string {\n const elapsed = Math.round((Date.now() - session.startedAt) / 60_000);\n const s = session.stats;\n const parts = [\n `Session: ${elapsed} min`,\n `Tools: ${s.toolCalls} calls (${s.toolErrors} error${s.toolErrors !== 1 ? \"s\" : \"\"})`,\n `Files: ${s.fileChanges} changed`,\n `Blockers: ${s.blockers}`,\n `Milestones: ${s.milestones}`,\n ];\n if (s.topicShifts > 0) parts.push(`Topic shifts: ${s.topicShifts}`);\n if (session.paused) parts.push(\"(paused)\");\n return parts.join(\" | \");\n}\n\nexport async function cleanupOldObservations(\n dir?: string,\n maxAgeDays = 30,\n): Promise<void> {\n const obsDir = dir ?? defaultObservationsDir();\n try {\n const files = await fs.readdir(obsDir);\n const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;\n\n for (const file of files) {\n if (!file.endsWith(\".jsonl\")) continue;\n const filePath = path.join(obsDir, file);\n const stat = await fs.stat(filePath);\n if (stat.mtimeMs < cutoff) {\n await fs.unlink(filePath);\n }\n }\n } catch {\n // Directory may not exist yet — that's fine\n }\n}\n\n// ── Topic shift detection ──\n\nexport function detectTopicShift(\n recentMessages: string[],\n previousMessages: string[],\n): { shifted: boolean; newTopics: string[] } {\n const extractKeywords = (msgs: string[]): Set<string> => {\n const words = msgs\n .join(\" \")\n .toLowerCase()\n .split(/\\W+/)\n .filter((w) => w.length > 3);\n return new Set(words);\n };\n\n const recent = extractKeywords(recentMessages);\n const previous = extractKeywords(previousMessages);\n\n if (previous.size === 0) return { shifted: false, newTopics: [] };\n\n let overlap = 0;\n for (const word of recent) {\n if (previous.has(word)) overlap++;\n }\n\n const overlapRatio = previous.size > 0 ? overlap / previous.size : 1;\n const shifted = overlapRatio < 0.3;\n\n const newTopics = shifted\n ? [...recent].filter((w) => !previous.has(w)).slice(0, 5)\n : [];\n\n return { shifted, newTopics };\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { log } from \"./logger.js\";\n\n// ── Types ──\n\nexport interface SkillCandidate {\n name: string;\n description: string;\n triggers: string[];\n approach: string;\n steps: string[];\n gotchas: string[];\n confidence: number;\n}\n\nexport interface CrystallizationResult {\n written: boolean;\n filePath: string;\n skillName: string;\n reason?: string;\n collidesWith?: string;\n}\n\nexport interface MarkerData {\n source: string;\n date: string;\n confidence: number;\n triggers: string[];\n}\n\nexport interface CrystallizationLogEntry {\n name: string;\n createdAt: string;\n fromPostmortem: string;\n confidence: number;\n triggers: string[];\n}\n\nexport interface RejectionLogEntry {\n name: string;\n rejectedAt: string;\n fromPostmortem: string;\n triggers: string[];\n}\n\nexport interface CollisionResult {\n collides: boolean;\n collidesWith?: string;\n reason?: string;\n}\n\n// ── Constants ──\n\nconst STOPWORDS = new Set([\n \"the\", \"and\", \"is\", \"to\", \"of\", \"a\", \"in\", \"for\", \"on\", \"with\",\n \"this\", \"that\", \"it\", \"as\", \"be\", \"by\", \"or\", \"at\", \"an\", \"from\",\n \"code\", \"fix\", \"do\", \"use\", \"make\", \"get\", \"set\", \"run\", \"we\", \"i\",\n]);\n\nconst MAX_REJECTIONS = 100;\nconst MARKER_RE = /<!--\\s*aman-auto\\s+([^>]+?)\\s*-->/;\n\n// ── sanitizeName ──\n\nexport function sanitizeName(input: string): string {\n const cleaned = input\n .toLowerCase()\n .trim()\n .replace(/[^a-z0-9\\s-]/g, \" \")\n .replace(/\\s+/g, \"-\")\n .replace(/-+/g, \"-\")\n .replace(/^-|-$/g, \"\");\n\n if (cleaned.length === 0) {\n throw new Error(`Cannot sanitize name: \"${input}\" produced empty result`);\n }\n return cleaned;\n}\n\n// ── validateCandidate ──\n\nexport function validateCandidate(raw: unknown): SkillCandidate | null {\n if (!raw || typeof raw !== \"object\") return null;\n const c = raw as Record<string, unknown>;\n\n if (typeof c.name !== \"string\" || c.name.trim() === \"\") return null;\n if (typeof c.description !== \"string\") return null;\n if (typeof c.approach !== \"string\") return null;\n if (!Array.isArray(c.triggers) || c.triggers.length === 0) return null;\n if (c.triggers.length > 10) return null;\n if (!Array.isArray(c.steps)) return null;\n if (typeof c.confidence !== \"number\") return null;\n if (!Number.isFinite(c.confidence)) return null;\n\n if (c.confidence < 0.6) return null;\n\n const triggers = Array.from(\n new Set(\n c.triggers\n .filter((t): t is string => typeof t === \"string\")\n .map((t) => t.toLowerCase().trim())\n .filter((t) => t.length > 0 && !STOPWORDS.has(t))\n )\n );\n\n if (triggers.length === 0) return null;\n\n let name: string;\n try {\n name = sanitizeName(c.name);\n } catch {\n return null;\n }\n\n return {\n name,\n description: c.description,\n triggers,\n approach: c.approach,\n steps: c.steps.filter((s): s is string => typeof s === \"string\"),\n gotchas: Array.isArray(c.gotchas)\n ? c.gotchas.filter((g): g is string => typeof g === \"string\")\n : [],\n confidence: Math.min(1, Math.max(0, c.confidence)),\n };\n}\n\n// ── formatSkillMarkdown ──\n\nfunction toTitleCase(kebab: string): string {\n return kebab\n .split(\"-\")\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(\" \");\n}\n\nexport function formatSkillMarkdown(\n candidate: SkillCandidate,\n postmortemFilename: string,\n): string {\n const date = new Date().toISOString().slice(0, 10);\n const heading = toTitleCase(candidate.name);\n const triggerStr = candidate.triggers.join(\",\");\n\n const lines: string[] = [\n `# ${heading}`,\n `<!-- aman-auto source=postmortem date=${date} confidence=${candidate.confidence} triggers=\"${triggerStr}\" -->`,\n \"\",\n \"## When to use\",\n candidate.approach,\n \"\",\n \"## Steps\",\n ...candidate.steps.map((s, i) => `${i + 1}. ${s}`),\n \"\",\n ];\n\n if (candidate.gotchas.length > 0) {\n lines.push(\"## Gotchas\");\n lines.push(...candidate.gotchas.map((g) => `- ${g}`));\n lines.push(\"\");\n }\n\n lines.push(`<!-- generated from ${postmortemFilename} -->`);\n lines.push(\"\");\n\n return lines.join(\"\\n\");\n}\n\n// ── parseMarkerComment ──\n\nexport function parseMarkerComment(line: string): MarkerData | null {\n const match = line.match(MARKER_RE);\n if (!match) return null;\n\n const attrs: Record<string, string> = {};\n const attrRe = /(\\w+)=(?:\"([^\"]*)\"|(\\S+))/g;\n let m: RegExpExecArray | null;\n while ((m = attrRe.exec(match[1])) !== null) {\n attrs[m[1]] = m[2] ?? m[3] ?? \"\";\n }\n\n if (!attrs.triggers) return null;\n\n const triggers = attrs.triggers\n .split(\",\")\n .map((t) => t.trim())\n .filter((t) => t.length > 0);\n\n if (triggers.length === 0) return null;\n\n return {\n source: attrs.source ?? \"unknown\",\n date: attrs.date ?? \"\",\n confidence: attrs.confidence ? Number(attrs.confidence) : 0,\n triggers,\n };\n}\n\n// ── extractSkillsWithMarkers ──\n\nexport function extractSkillsWithMarkers(\n skillsMdContent: string,\n): Map<string, MarkerData> {\n const result = new Map<string, MarkerData>();\n const lines = skillsMdContent.split(\"\\n\");\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (line.startsWith(\"# \") && i + 1 < lines.length) {\n const headingText = line.slice(2).trim();\n const nextLine = lines[i + 1];\n const marker = parseMarkerComment(nextLine);\n if (marker) {\n try {\n const skillName = sanitizeName(headingText);\n result.set(skillName, marker);\n } catch {\n log.debug(\"crystallization\", `cannot sanitize heading: ${headingText}`);\n }\n }\n }\n }\n\n return result;\n}\n\n// ── findCollision ──\n\nexport function findCollision(\n name: string,\n triggers: string[],\n existing: Map<string, MarkerData>,\n): CollisionResult {\n if (existing.has(name)) {\n return { collides: true, collidesWith: name, reason: \"exact name match\" };\n }\n\n const triggerSet = new Set(triggers);\n for (const [otherName, otherData] of existing) {\n const otherTriggers = new Set(otherData.triggers);\n const intersection = [...triggerSet].filter((t) => otherTriggers.has(t)).length;\n const union = new Set([...triggerSet, ...otherTriggers]).size;\n const overlap = union > 0 ? intersection / union : 0;\n if (overlap >= 0.8) {\n return {\n collides: true,\n collidesWith: otherName,\n reason: `${Math.round(overlap * 100)}% trigger overlap`,\n };\n }\n }\n\n return { collides: false };\n}\n\n// ── writeSkillToFile ──\n\nexport async function writeSkillToFile(\n candidate: SkillCandidate,\n skillsMdPath: string,\n postmortemFilename: string,\n): Promise<CrystallizationResult> {\n try {\n await fs.mkdir(path.dirname(skillsMdPath), { recursive: true });\n\n let existingContent = \"\";\n try {\n existingContent = await fs.readFile(skillsMdPath, \"utf-8\");\n } catch {\n existingContent = \"# Skills\\n\\n\";\n }\n\n if (existingContent.trim() === \"\") {\n existingContent = \"# Skills\\n\\n\";\n }\n\n const existingSkills = extractSkillsWithMarkers(existingContent);\n const collision = findCollision(candidate.name, candidate.triggers, existingSkills);\n if (collision.collides) {\n log.debug(\"crystallization\", `collision detected: ${collision.reason}`);\n return {\n written: false,\n filePath: skillsMdPath,\n skillName: candidate.name,\n reason: `collision with \"${collision.collidesWith}\" (${collision.reason})`,\n collidesWith: collision.collidesWith,\n };\n }\n\n const skillMarkdown = formatSkillMarkdown(candidate, postmortemFilename);\n const separator = existingContent.endsWith(\"\\n\\n\")\n ? \"\"\n : existingContent.endsWith(\"\\n\")\n ? \"\\n\"\n : \"\\n\\n\";\n await fs.writeFile(\n skillsMdPath,\n existingContent + separator + skillMarkdown,\n \"utf-8\",\n );\n\n return {\n written: true,\n filePath: skillsMdPath,\n skillName: candidate.name,\n };\n } catch (err) {\n log.warn(\"crystallization\", \"writeSkillToFile failed\", err);\n return {\n written: false,\n filePath: skillsMdPath,\n skillName: candidate.name,\n reason: err instanceof Error ? err.message : String(err),\n };\n }\n}\n\n// ── mergeSkillInFile ──\n\n/**\n * Replace an existing skill block in skills.md with a new candidate.\n * Finds the heading for `existingName`, removes everything up to the next heading or EOF,\n * and writes the new candidate in its place.\n */\nexport async function mergeSkillInFile(\n candidate: SkillCandidate,\n existingName: string,\n skillsMdPath: string,\n postmortemFilename: string,\n): Promise<CrystallizationResult> {\n try {\n const content = await fs.readFile(skillsMdPath, \"utf-8\");\n const lines = content.split(\"\\n\");\n\n // Find the heading line for the existing skill\n const heading = toTitleCase(existingName);\n let startIdx = -1;\n for (let i = 0; i < lines.length; i++) {\n if (lines[i].startsWith(\"# \") && lines[i].slice(2).trim() === heading) {\n startIdx = i;\n break;\n }\n }\n\n if (startIdx === -1) {\n return writeSkillToFile(candidate, skillsMdPath, postmortemFilename);\n }\n\n // Find the end of this skill block (next heading or EOF)\n let endIdx = lines.length;\n for (let i = startIdx + 1; i < lines.length; i++) {\n if (lines[i].startsWith(\"# \") && !lines[i].startsWith(\"## \")) {\n endIdx = i;\n break;\n }\n }\n\n // Determine version number — count existing archived versions\n const versionPattern = new RegExp(`^# ${heading.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\\\.v\\\\d+`);\n let maxVersion = 0;\n for (const line of lines) {\n if (versionPattern.test(line)) {\n const vMatch = line.match(/\\.v(\\d+)/);\n if (vMatch) maxVersion = Math.max(maxVersion, parseInt(vMatch[1], 10));\n }\n }\n const archiveVersion = maxVersion + 1;\n\n // Archive the old version by renaming its heading\n const oldBlock = lines.slice(startIdx, endIdx);\n oldBlock[0] = `# ${heading}.v${archiveVersion}`;\n // Add archive marker\n const archiveMarker = `<!-- aman-archived version=${archiveVersion} archived-at=${new Date().toISOString().slice(0, 10)} -->`;\n if (oldBlock.length > 1 && oldBlock[1].includes(\"aman-auto\")) {\n oldBlock.splice(2, 0, archiveMarker);\n } else {\n oldBlock.splice(1, 0, archiveMarker);\n }\n\n // Write: archived old block + new candidate\n const newSkillMarkdown = formatSkillMarkdown(candidate, postmortemFilename);\n const before = lines.slice(0, startIdx);\n const after = lines.slice(endIdx);\n const merged = [...before, ...oldBlock, \"\", newSkillMarkdown, ...after].join(\"\\n\");\n\n await fs.writeFile(skillsMdPath, merged, \"utf-8\");\n\n return {\n written: true,\n filePath: skillsMdPath,\n skillName: candidate.name,\n reason: `merged with \"${existingName}\" (archived as .v${archiveVersion})`,\n };\n } catch (err) {\n log.warn(\"crystallization\", \"mergeSkillInFile failed\", err);\n return {\n written: false,\n filePath: skillsMdPath,\n skillName: candidate.name,\n reason: err instanceof Error ? err.message : String(err),\n };\n }\n}\n\n// ── Logs ──\n\nexport async function appendCrystallizationLog(\n entry: CrystallizationLogEntry,\n logPath: string,\n): Promise<void> {\n try {\n await fs.mkdir(path.dirname(logPath), { recursive: true });\n let existing: CrystallizationLogEntry[] = [];\n try {\n const content = await fs.readFile(logPath, \"utf-8\");\n existing = JSON.parse(content);\n if (!Array.isArray(existing)) existing = [];\n } catch {\n existing = [];\n }\n existing.push(entry);\n await fs.writeFile(logPath, JSON.stringify(existing, null, 2), \"utf-8\");\n } catch (err) {\n log.debug(\"crystallization\", \"appendCrystallizationLog failed\", err);\n }\n}\n\nexport async function appendRejection(\n candidate: SkillCandidate,\n postmortemFilename: string,\n rejectionsPath: string,\n): Promise<void> {\n try {\n await fs.mkdir(path.dirname(rejectionsPath), { recursive: true });\n let existing: RejectionLogEntry[] = [];\n try {\n const content = await fs.readFile(rejectionsPath, \"utf-8\");\n existing = JSON.parse(content);\n if (!Array.isArray(existing)) existing = [];\n } catch {\n existing = [];\n }\n\n existing.push({\n name: candidate.name,\n rejectedAt: new Date().toISOString(),\n fromPostmortem: postmortemFilename,\n triggers: candidate.triggers,\n });\n\n while (existing.length > MAX_REJECTIONS) {\n existing.shift();\n }\n\n await fs.writeFile(rejectionsPath, JSON.stringify(existing, null, 2), \"utf-8\");\n } catch (err) {\n log.debug(\"crystallization\", \"appendRejection failed\", err);\n }\n}\n\n/**\n * Load rejected skill names from the rejections log.\n * Returns unique names. Never throws.\n */\nexport async function loadRejectedNames(rejectionsPath: string): Promise<string[]> {\n try {\n const content = await fs.readFile(rejectionsPath, \"utf-8\");\n const entries: RejectionLogEntry[] = JSON.parse(content);\n if (!Array.isArray(entries)) return [];\n return [...new Set(entries.map((e) => e.name))];\n } catch {\n return [];\n }\n}\n\n// ── Suggestion tracking (cross-session reinforcement) ──\n\nexport interface SuggestionCounts {\n [name: string]: number;\n}\n\n/**\n * Load suggestion counts. Never throws.\n */\nexport async function loadSuggestionCounts(suggestionsPath: string): Promise<SuggestionCounts> {\n try {\n const content = await fs.readFile(suggestionsPath, \"utf-8\");\n const parsed = JSON.parse(content);\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) return {};\n return parsed as SuggestionCounts;\n } catch {\n return {};\n }\n}\n\n/**\n * Increment suggestion count for a candidate name. Returns the new count.\n */\nexport async function incrementSuggestionCount(\n name: string,\n suggestionsPath: string,\n): Promise<number> {\n try {\n await fs.mkdir(path.dirname(suggestionsPath), { recursive: true });\n const counts = await loadSuggestionCounts(suggestionsPath);\n counts[name] = (counts[name] || 0) + 1;\n await fs.writeFile(suggestionsPath, JSON.stringify(counts, null, 2), \"utf-8\");\n return counts[name];\n } catch (err) {\n log.debug(\"crystallization\", \"incrementSuggestionCount failed\", err);\n return 0;\n }\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { log } from \"./logger.js\";\n\n// ── Types ──\n\nexport interface SessionSnapshot {\n sessionId: string;\n date: string;\n durationMinutes: number;\n turnCount: number;\n\n dominantSentiment: string;\n avgFrustration: number;\n avgExcitement: number;\n avgConfusion: number;\n avgFatigue: number;\n\n toolCalls: number;\n toolErrors: number;\n blockers: number;\n milestones: number;\n topicShifts: number;\n\n peakEnergy: string;\n primaryMode: string;\n timePeriod: string;\n\n rating?: string;\n hadPostmortem: boolean;\n wellbeingNudges: string[];\n}\n\nexport interface UserProfile {\n trustScore: number;\n trustTrajectory: \"ascending\" | \"stable\" | \"declining\";\n totalSessions: number;\n\n preferredTimePeriod: string;\n energyDistribution: Record<string, number>;\n avgSessionMinutes: number;\n\n baselineFrustration: number;\n baselineExcitement: number;\n sentimentTrend: \"improving\" | \"stable\" | \"worsening\";\n\n frustrationCorrelations: {\n toolErrors: number;\n longSessions: number;\n lateNight: number;\n };\n\n avgTurnsPerSession: number;\n engagementTrend: \"increasing\" | \"stable\" | \"decreasing\";\n\n nudgeStats: Record<string, { fired: number; sessionRatingAfter: number }>;\n}\n\nexport interface UserModel {\n version: 1;\n sessions: SessionSnapshot[];\n profile: UserProfile;\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface PersonalityOverrides {\n energyOverride?: string;\n compactGreeting: boolean;\n frustrationNudgeThreshold: number;\n defaultToPersonalMode: boolean;\n}\n\n// ── Constants ──\n\nconst MAX_SESSIONS = 30;\nconst TRUST_ALPHA = 0.3;\nconst MIN_SESSIONS_FOR_FEED_FORWARD = 5;\nconst MIN_SESSIONS_FOR_CORRELATIONS = 10;\n\n// ── Default model path ──\n\nexport function defaultModelPath(): string {\n return path.join(os.homedir(), \".acore\", \"user-model.json\");\n}\n\n// ── Factory ──\n\nexport function createEmptyModel(): UserModel {\n const now = new Date().toISOString();\n return {\n version: 1,\n sessions: [],\n profile: emptyProfile(),\n createdAt: now,\n updatedAt: now,\n };\n}\n\nfunction emptyProfile(): UserProfile {\n return {\n trustScore: 0.5,\n trustTrajectory: \"stable\",\n totalSessions: 0,\n preferredTimePeriod: \"afternoon\",\n energyDistribution: {},\n avgSessionMinutes: 0,\n baselineFrustration: 0,\n baselineExcitement: 0,\n sentimentTrend: \"stable\",\n frustrationCorrelations: { toolErrors: 0, longSessions: 0, lateNight: 0 },\n avgTurnsPerSession: 0,\n engagementTrend: \"stable\",\n nudgeStats: {},\n };\n}\n\n// ── I/O ──\n\nexport async function loadUserModel(filePath?: string): Promise<UserModel | null> {\n const fp = filePath ?? defaultModelPath();\n try {\n const raw = await fs.readFile(fp, \"utf-8\");\n const parsed = JSON.parse(raw);\n if (parsed?.version !== 1) return null;\n return parsed as UserModel;\n } catch {\n return null;\n }\n}\n\nexport async function saveUserModel(model: UserModel, filePath?: string): Promise<void> {\n const fp = filePath ?? defaultModelPath();\n const dir = path.dirname(fp);\n await fs.mkdir(dir, { recursive: true });\n\n const tmp = fp + `.tmp-${Date.now()}`;\n await fs.writeFile(tmp, JSON.stringify(model, null, 2), \"utf-8\");\n await fs.rename(tmp, fp);\n}\n\n// ── Aggregation ──\n\nexport function aggregateSession(model: UserModel, snapshot: SessionSnapshot): UserModel {\n const sessions = [...model.sessions, snapshot];\n\n // Enforce rolling window\n while (sessions.length > MAX_SESSIONS) {\n sessions.shift();\n }\n\n const totalSessions = model.profile.totalSessions + 1;\n const profile = computeProfile(sessions, totalSessions);\n\n return {\n ...model,\n sessions,\n profile,\n updatedAt: new Date().toISOString(),\n };\n}\n\n// ── Profile Computation ──\n\nexport function computeProfile(sessions: SessionSnapshot[], totalSessions: number): UserProfile {\n if (sessions.length === 0) return { ...emptyProfile(), totalSessions };\n\n const n = sessions.length;\n\n // ── Trust Score (EMA) ──\n let trustScore = 0.5;\n for (const s of sessions) {\n trustScore = TRUST_ALPHA * ratingSignal(s) + (1 - TRUST_ALPHA) * trustScore;\n }\n\n // Trust trajectory: compare last 5 vs previous 5\n const trustTrajectory = computeTrustTrajectory(sessions);\n\n // ── Sentiment Baselines ──\n const baselineFrustration = avg(sessions.map((s) => s.avgFrustration));\n const baselineExcitement = avg(sessions.map((s) => s.avgExcitement));\n const sentimentTrend = computeSentimentTrend(sessions);\n\n // ── Energy Distribution ──\n const energyDistribution: Record<string, number> = {};\n for (const s of sessions) {\n energyDistribution[s.timePeriod] = (energyDistribution[s.timePeriod] || 0) + 1;\n }\n const preferredTimePeriod = Object.entries(energyDistribution).sort(\n (a, b) => b[1] - a[1],\n )[0]?.[0] ?? \"afternoon\";\n\n // ── Session Duration ──\n const avgSessionMinutes = avg(sessions.map((s) => s.durationMinutes));\n\n // ── Engagement ──\n const avgTurnsPerSession = avg(sessions.map((s) => s.turnCount));\n const engagementTrend = computeLinearTrend(sessions.map((s) => s.turnCount));\n\n // ── Frustration Correlations ──\n const frustrationCorrelations =\n n >= MIN_SESSIONS_FOR_CORRELATIONS\n ? {\n toolErrors: pearsonR(\n sessions.map((s) => s.avgFrustration),\n sessions.map((s) => s.toolErrors),\n ),\n longSessions: pearsonR(\n sessions.map((s) => s.avgFrustration),\n sessions.map((s) => s.durationMinutes),\n ),\n lateNight: pearsonR(\n sessions.map((s) => s.avgFrustration),\n sessions.map((s) => (s.timePeriod === \"late-night\" || s.timePeriod === \"night\" ? 1 : 0)),\n ),\n }\n : { toolErrors: 0, longSessions: 0, lateNight: 0 };\n\n // ── Nudge Stats ──\n const nudgeStats: Record<string, { fired: number; sessionRatingAfter: number }> = {};\n for (const s of sessions) {\n const ratingVal = ratingToNumber(s.rating);\n for (const nudge of s.wellbeingNudges) {\n if (!nudgeStats[nudge]) nudgeStats[nudge] = { fired: 0, sessionRatingAfter: 0 };\n nudgeStats[nudge].fired++;\n nudgeStats[nudge].sessionRatingAfter += ratingVal;\n }\n }\n for (const key of Object.keys(nudgeStats)) {\n if (nudgeStats[key].fired > 0) {\n nudgeStats[key].sessionRatingAfter /= nudgeStats[key].fired;\n }\n }\n\n return {\n trustScore,\n trustTrajectory,\n totalSessions,\n preferredTimePeriod,\n energyDistribution,\n avgSessionMinutes,\n baselineFrustration,\n baselineExcitement,\n sentimentTrend,\n frustrationCorrelations,\n avgTurnsPerSession,\n engagementTrend,\n nudgeStats,\n };\n}\n\n// ── Feed-Forward ──\n\nexport function feedForward(model: UserModel): PersonalityOverrides | null {\n if (model.profile.totalSessions < MIN_SESSIONS_FOR_FEED_FORWARD) return null;\n\n const p = model.profile;\n const overrides: PersonalityOverrides = {\n compactGreeting: false,\n frustrationNudgeThreshold: 0.6,\n defaultToPersonalMode: false,\n };\n\n // Night owl calibration: if 70%+ sessions are late-night/night with low frustration,\n // don't default to \"reflective\" — use \"steady\"\n const nightSessions =\n (p.energyDistribution[\"late-night\"] || 0) + (p.energyDistribution[\"night\"] || 0);\n const totalInWindow = model.sessions.length;\n if (totalInWindow > 0 && nightSessions / totalInWindow >= 0.7 && p.baselineFrustration < 0.3) {\n overrides.energyOverride = \"steady\";\n }\n\n // High trust → compact greeting\n if (p.trustScore > 0.8) {\n overrides.compactGreeting = true;\n }\n\n // Tool error frustration correlation → lower nudge threshold\n if (p.frustrationCorrelations.toolErrors > 0.4) {\n overrides.frustrationNudgeThreshold = 0.4;\n }\n\n // Worsening sentiment → default to Personal mode more readily\n if (p.sentimentTrend === \"worsening\") {\n overrides.defaultToPersonalMode = true;\n }\n\n return overrides;\n}\n\n// ── Burnout Predictor ──\n\nexport interface BurnoutPrediction {\n risk: number; // 0-1\n factors: string[];\n recommendation?: string;\n}\n\n/**\n * Predict burnout risk from session patterns.\n * Looks at recent 7 sessions for:\n * - Rising frustration trend\n * - Declining session ratings\n * - Long sessions without breaks\n * - Late-night clustering\n * - High blocker frequency\n */\nexport function predictBurnout(\n sessions: SessionSnapshot[],\n currentSession?: { minutes: number; frustration: number; timePeriod: string },\n): BurnoutPrediction {\n const recent = sessions.slice(-7);\n if (recent.length < 3) {\n return { risk: 0, factors: [] };\n }\n\n const factors: string[] = [];\n let risk = 0;\n\n // Factor 1: Rising frustration (compare first half vs second half)\n const mid = Math.floor(recent.length / 2);\n const firstHalf = recent.slice(0, mid);\n const secondHalf = recent.slice(mid);\n const avgFrustFirst = avg(firstHalf.map((s) => s.avgFrustration));\n const avgFrustSecond = avg(secondHalf.map((s) => s.avgFrustration));\n if (avgFrustSecond > avgFrustFirst + 0.1 && avgFrustSecond > 0.4) {\n risk += 0.25;\n factors.push(\"rising frustration trend\");\n }\n\n // Factor 2: Declining ratings\n const ratings = recent.filter((s) => s.rating).map((s) => ratingSignal(s));\n if (ratings.length >= 3) {\n const lastThree = ratings.slice(-3);\n const avgLast3 = avg(lastThree);\n if (avgLast3 < 0.5) {\n risk += 0.2;\n factors.push(\"low recent ratings\");\n }\n }\n\n // Factor 3: Long sessions (avg > 90 min)\n const avgMins = avg(recent.map((s) => s.durationMinutes));\n if (avgMins > 90) {\n risk += 0.15;\n factors.push(\"consistently long sessions\");\n }\n\n // Factor 4: Late-night clustering\n const lateNightCount = recent.filter((s) => s.timePeriod === \"late-night\" || s.timePeriod === \"night\").length;\n if (lateNightCount / recent.length > 0.5) {\n risk += 0.15;\n factors.push(\"frequent late-night sessions\");\n }\n\n // Factor 5: High blocker frequency\n const avgBlockers = avg(recent.map((s) => s.blockers));\n if (avgBlockers > 1) {\n risk += 0.15;\n factors.push(\"frequent blockers\");\n }\n\n // Current session amplifier\n if (currentSession) {\n if (currentSession.minutes > 120 && currentSession.frustration > 0.5) {\n risk += 0.1;\n factors.push(\"current session: long + frustrated\");\n }\n }\n\n risk = clamp(risk, 0, 1);\n\n let recommendation: string | undefined;\n if (risk > 0.7) {\n recommendation = \"Consider taking a longer break. You've been pushing hard — rest is productive too.\";\n } else if (risk > 0.5) {\n recommendation = \"Watch for signs of fatigue. A change of pace or shorter sessions might help.\";\n }\n\n return { risk, factors, recommendation };\n}\n\n// ── Math Utilities ──\n\nfunction clamp(val: number, min: number, max: number): number {\n return Math.max(min, Math.min(max, val));\n}\n\nfunction avg(values: number[]): number {\n if (values.length === 0) return 0;\n return values.reduce((sum, v) => sum + v, 0) / values.length;\n}\n\nfunction ratingSignal(session: SessionSnapshot): number {\n if (session.rating === \"great\") return 1.0;\n if (session.rating === \"good\") return 0.75;\n if (session.rating === \"okay\") return 0.5;\n if (session.rating === \"frustrating\") return 0.25;\n\n // No explicit rating — infer from signals\n let implicit = 1.0;\n implicit -= session.avgFrustration * 0.4;\n implicit -= session.toolErrors > 3 ? 0.2 : 0;\n implicit -= session.blockers > 2 ? 0.2 : 0;\n implicit += session.milestones > 0 ? 0.1 : 0;\n return clamp(implicit, 0, 1);\n}\n\nfunction ratingToNumber(rating?: string): number {\n if (rating === \"great\") return 1.0;\n if (rating === \"good\") return 0.75;\n if (rating === \"okay\") return 0.5;\n if (rating === \"frustrating\") return 0.25;\n return 0.5;\n}\n\nfunction pearsonR(x: number[], y: number[]): number {\n const n = x.length;\n if (n < 3) return 0;\n\n const mx = avg(x);\n const my = avg(y);\n\n let num = 0;\n let dx2 = 0;\n let dy2 = 0;\n\n for (let i = 0; i < n; i++) {\n const dx = x[i] - mx;\n const dy = y[i] - my;\n num += dx * dy;\n dx2 += dx * dx;\n dy2 += dy * dy;\n }\n\n const denom = Math.sqrt(dx2 * dy2);\n if (denom === 0) return 0;\n return num / denom;\n}\n\nfunction computeTrustTrajectory(\n sessions: SessionSnapshot[],\n): \"ascending\" | \"stable\" | \"declining\" {\n if (sessions.length < 10) return \"stable\";\n\n const recent5 = sessions.slice(-5).map(ratingSignal);\n const prev5 = sessions.slice(-10, -5).map(ratingSignal);\n\n const recentAvg = avg(recent5);\n const prevAvg = avg(prev5);\n const delta = recentAvg - prevAvg;\n\n if (delta > 0.1) return \"ascending\";\n if (delta < -0.1) return \"declining\";\n return \"stable\";\n}\n\nfunction computeSentimentTrend(sessions: SessionSnapshot[]): \"improving\" | \"stable\" | \"worsening\" {\n if (sessions.length < 5) return \"stable\";\n\n const frustrations = sessions.slice(-10).map((s) => s.avgFrustration);\n const slope = linearSlope(frustrations);\n\n if (slope > 0.02) return \"worsening\"; // frustration increasing = worsening\n if (slope < -0.02) return \"improving\"; // frustration decreasing = improving\n return \"stable\";\n}\n\nfunction computeLinearTrend(values: number[]): \"increasing\" | \"stable\" | \"decreasing\" {\n if (values.length < 5) return \"stable\";\n\n const recent = values.slice(-10);\n const slope = linearSlope(recent);\n\n // Normalize slope relative to mean to detect meaningful changes\n const mean = avg(recent);\n const relativeSlope = mean > 0 ? slope / mean : slope;\n\n if (relativeSlope > 0.03) return \"increasing\";\n if (relativeSlope < -0.03) return \"decreasing\";\n return \"stable\";\n}\n\nfunction linearSlope(values: number[]): number {\n const n = values.length;\n if (n < 2) return 0;\n\n let sumX = 0;\n let sumY = 0;\n let sumXY = 0;\n let sumX2 = 0;\n\n for (let i = 0; i < n; i++) {\n sumX += i;\n sumY += values[i];\n sumXY += i * values[i];\n sumX2 += i * i;\n }\n\n const denom = n * sumX2 - sumX * sumX;\n if (denom === 0) return 0;\n return (n * sumXY - sumX * sumY) / denom;\n}\n"],"mappings":"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","names":["fs","path","os","fs","path","os","pc","fs","path","os","fs","path","path","fs","path","fs","os","fs","path","os","path","os","fs","fs","path","os","fs","path","os","fs","path","fs","path","os","delegateRemote","recall","pc"]}
1
+ {"version":3,"sources":["../src/logger.ts","../src/server/registry.ts","../src/delegate-remote.ts","../src/delegate.ts","../src/prompt.ts","../src/token-budget.ts","../src/user-identity.ts","../src/config.ts","../src/retry.ts","../src/hooks.ts","../src/personality.ts","../src/memory.ts","../src/postmortem.ts","../src/observation.ts","../src/crystallization.ts","../src/user-model.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\n\nconst LOG_DIR = path.join(os.homedir(), \".aman-agent\");\nexport const LOG_PATH = path.join(LOG_DIR, \"debug.log\");\nconst MAX_LOG_SIZE = 1_048_576; // 1MB\n\ninterface LogEntry {\n timestamp: string;\n level: \"debug\" | \"warn\" | \"error\";\n module: string;\n message: string;\n data?: string;\n}\n\nfunction ensureDir(): void {\n if (!fs.existsSync(LOG_DIR)) {\n fs.mkdirSync(LOG_DIR, { recursive: true });\n }\n}\n\nfunction maybeRotate(): void {\n try {\n if (!fs.existsSync(LOG_PATH)) return;\n const stat = fs.statSync(LOG_PATH);\n if (stat.size >= MAX_LOG_SIZE) {\n const backupPath = LOG_PATH + \".1\";\n if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath);\n fs.renameSync(LOG_PATH, backupPath);\n }\n } catch {\n // Rotation failure is non-critical\n }\n}\n\nfunction write(level: LogEntry[\"level\"], module: string, message: string, data?: unknown): void {\n try {\n ensureDir();\n maybeRotate();\n const entry: LogEntry = {\n timestamp: new Date().toISOString(),\n level,\n module,\n message,\n };\n if (data !== undefined) {\n entry.data = data instanceof Error ? data.message : String(data);\n }\n fs.appendFileSync(LOG_PATH, JSON.stringify(entry) + \"\\n\");\n } catch {\n // Logger must never throw\n }\n}\n\nexport const log = {\n debug: (module: string, message: string, data?: unknown) => write(\"debug\", module, message, data),\n warn: (module: string, message: string, data?: unknown) => write(\"warn\", module, message, data),\n error: (module: string, message: string, data?: unknown) => write(\"error\", module, message, data),\n};\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { log } from \"../logger.js\";\n\nexport interface AgentEntry {\n name: string; // unique handle, used as @name\n profile: string; // aman-agent profile this server loaded\n pid: number; // process id for liveness check\n port: number; // 127.0.0.1 port\n token: string; // 32-byte hex bearer\n started_at: number; // epoch ms\n version: string; // package version\n}\n\nexport interface ListOptions {\n prune?: boolean; // write the pruned registry back\n isAlive?: (pid: number) => boolean; // injectable for tests\n}\n\nfunction amanAgentHome(): string {\n return process.env.AMAN_AGENT_HOME || path.join(os.homedir(), \".aman-agent\");\n}\n\nfunction registryPath(): string {\n return path.join(amanAgentHome(), \"registry.json\");\n}\n\nasync function ensureHome(): Promise<void> {\n await fs.mkdir(amanAgentHome(), { recursive: true });\n}\n\nasync function readRaw(): Promise<AgentEntry[]> {\n try {\n const buf = await fs.readFile(registryPath(), \"utf-8\");\n const parsed = JSON.parse(buf);\n return Array.isArray(parsed) ? parsed : [];\n } catch (err: unknown) {\n const code = (err as { code?: string }).code;\n if (code === \"ENOENT\") return [];\n const message = err instanceof Error ? err.message : String(err);\n log.warn(\"registry\", `failed to read registry: ${message}`);\n return [];\n }\n}\n\nasync function writeAtomic(entries: AgentEntry[]): Promise<void> {\n await ensureHome();\n const tmp = registryPath() + \".tmp\";\n await fs.writeFile(tmp, JSON.stringify(entries, null, 2), { mode: 0o600 });\n await fs.rename(tmp, registryPath());\n // Ensure mode even if file already existed (chmod is idempotent).\n try {\n await fs.chmod(registryPath(), 0o600);\n } catch {\n // best effort\n }\n}\n\nfunction defaultIsAlive(pid: number): boolean {\n try {\n // Signal 0 probes existence without sending anything.\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\nexport async function registerAgent(entry: AgentEntry): Promise<void> {\n const current = await readRaw();\n const filtered = current.filter((e) => e.name !== entry.name);\n if (filtered.length !== current.length) {\n log.warn(\"registry\", `replacing existing entry for name=\"${entry.name}\"`);\n }\n filtered.push(entry);\n await writeAtomic(filtered);\n}\n\nexport async function unregisterAgent(name: string): Promise<void> {\n const current = await readRaw();\n const next = current.filter((e) => e.name !== name);\n if (next.length !== current.length) {\n await writeAtomic(next);\n }\n}\n\nexport async function listAgents(opts: ListOptions = {}): Promise<AgentEntry[]> {\n const isAlive = opts.isAlive ?? defaultIsAlive;\n const raw = await readRaw();\n const alive = raw.filter((e) => isAlive(e.pid));\n if (opts.prune && alive.length !== raw.length) {\n await writeAtomic(alive);\n }\n return alive;\n}\n\nexport async function findAgent(name: string): Promise<AgentEntry | null> {\n const all = await listAgents();\n return all.find((e) => e.name === name) ?? null;\n}\n","import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { findAgent } from \"./server/registry.js\";\nimport type { DelegationResult } from \"./delegate.js\";\nimport { log } from \"./logger.js\";\n\nexport interface RemoteDelegateOptions {\n context?: string;\n timeoutMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 120_000;\n\n/**\n * Dial another aman-agent running as an A2A server on the same machine\n * and run a task through its `agent.delegate` MCP tool. Returns a\n * DelegationResult matching the shape of the local `delegateTask` so\n * callers can treat local and remote delegation uniformly.\n *\n * Trust model: same user, same machine — bearer comes from the local\n * registry file (mode 0600). See plan docs for the broader discussion.\n */\nexport async function delegateRemote(\n task: string,\n agentName: string,\n options: RemoteDelegateOptions = {},\n): Promise<DelegationResult> {\n const entry = await findAgent(agentName);\n if (!entry) {\n return {\n profile: `@${agentName}`,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: `agent not found: ${agentName}`,\n };\n }\n\n const url = new URL(`http://127.0.0.1:${entry.port}/mcp`);\n const transport = new StreamableHTTPClientTransport(url, {\n requestInit: {\n headers: { Authorization: `Bearer ${entry.token}` },\n },\n // Disable SSE reconnection scheduling. On close(), the SDK aborts\n // the controller; without this override, the SSE stream's error\n // handler races to schedule a new _reconnectionTimeout AFTER close()\n // cleared the old one, and the timer (plus its referenced socket)\n // pins Node's event loop until the undici keepalive times out. A\n // delegateRemote caller then can't exit cleanly. maxRetries: 0\n // drops the schedule-on-error path entirely; we're doing a single\n // RPC, not a persistent stream, so reconnection has no value here.\n reconnectionOptions: {\n maxRetries: 0,\n initialReconnectionDelay: 1,\n maxReconnectionDelay: 1,\n reconnectionDelayGrowFactor: 1,\n },\n });\n const client = new Client({ name: \"aman-agent-a2a-caller\", version: \"0.1.0\" });\n\n try {\n await client.connect(transport);\n\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const call = client.callTool({\n name: \"agent.delegate\",\n arguments: {\n task,\n ...(options.context ? { context: options.context } : {}),\n },\n });\n\n // Promise.race picks a winner but does NOT cancel the losing promise's\n // resources. Capturing the timer id lets us clear it after the call\n // resolves — otherwise the setTimeout keeps a Timeout handle alive for\n // the full timeoutMs (120 s default) and pins Node's event loop long\n // after the caller thinks the RPC is done. Equivalent effect to using\n // AbortSignal.timeout() but keeps the existing error message.\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<never>((_, rej) => {\n timeoutId = setTimeout(\n () => rej(new Error(`remote delegate timed out after ${timeoutMs}ms`)),\n timeoutMs,\n );\n });\n let result;\n try {\n result = await Promise.race([call, timeout]);\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n }\n\n const text = Array.isArray(result.content)\n ? (result.content as Array<{ type: string; text?: string }>)\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\")\n : \"\";\n\n // MCP tool-level errors arrive as { isError: true, content: [{text: \"...\"}] }.\n // Surface them distinctly from JSON.parse failures and from empty responses.\n if ((result as { isError?: boolean }).isError) {\n return {\n profile: `@${agentName}`,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: `remote tool error: ${text || \"(no details)\"}`,\n };\n }\n\n const parsed = text ? JSON.parse(text) : { ok: false, error: \"empty response\" };\n\n log.debug(\"delegate-remote\", `@${agentName} ok=${parsed.ok}`);\n\n if (!parsed.ok) {\n return {\n profile: `@${agentName}`,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: parsed.error ?? \"unknown remote error\",\n };\n }\n\n return {\n profile: `@${agentName}`,\n task,\n response: parsed.text ?? \"\",\n toolsUsed: parsed.tools_used ?? [],\n turns: parsed.turns ?? 0,\n success: true,\n };\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n const lower = msg.toLowerCase();\n const normalized =\n lower.includes(\"401\") || lower.includes(\"unauthor\")\n ? `unauthorized: ${msg}`\n : msg;\n return {\n profile: `@${agentName}`,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: normalized,\n };\n } finally {\n // Teardown order matters:\n // 1. terminateSession() sends an MCP DELETE to drop the server-side\n // session. This needs the transport's abort controller to still\n // be alive, so it MUST run BEFORE client.close() (which aborts\n // the controller). Earlier order threw DOMException[AbortError].\n // 2. client.close() then releases SDK-side state and aborts the\n // transport. Combined with reconnectionOptions: { maxRetries: 0 }\n // on construction, this leaves zero handles pinning the event\n // loop — verified via process.getActiveResourcesInfo() === [].\n // 3. transport.close() is a no-op after client.close() (which\n // transitively closes the transport) but kept for symmetry.\n // All three are best-effort: any throw here is swallowed so a\n // teardown failure never masks a real result from the caller.\n try { await transport.terminateSession(); } catch { /* best effort */ }\n try { await client.close(); } catch { /* best effort */ }\n try { await transport.close(); } catch { /* best effort */ }\n }\n}\n","import pc from \"picocolors\";\nimport type {\n LLMClient,\n Message,\n ToolDefinition,\n ToolResultBlock,\n StreamChunk,\n} from \"./llm/types.js\";\nimport type { McpManager } from \"./mcp/client.js\";\nimport { assembleSystemPrompt } from \"./prompt.js\";\nimport { withRetry } from \"./retry.js\";\nimport { log } from \"./logger.js\";\nimport { onBeforeToolExec, type HookContext } from \"./hooks.js\";\nimport { memoryRecall } from \"./memory.js\";\nimport type { HooksConfig } from \"./config.js\";\n\nexport interface DelegationResult {\n profile: string;\n task: string;\n response: string;\n toolsUsed: string[];\n turns: number;\n success: boolean;\n error?: string;\n}\n\nexport interface DelegateOptions {\n maxTurns?: number; // max tool loop iterations (default: 10)\n silent?: boolean; // suppress output (default: false)\n tools?: ToolDefinition[]; // tools available to sub-agent\n hooksConfig?: HooksConfig; // guardrail config for tool execution\n}\n\nconst isRetryable = (err: unknown): boolean => {\n if (err instanceof Error) {\n const msg = err.message.toLowerCase();\n return msg.includes(\"rate\") || msg.includes(\"timeout\") || msg.includes(\"econnreset\");\n }\n return false;\n};\n\n/**\n * Run a task with a specific profile as a non-interactive sub-agent.\n * The sub-agent gets its own system prompt (from profile), runs a mini agent loop\n * (LLM → tools → LLM → ...), and returns the final text response.\n *\n * Reuses the parent's LLM client and MCP connections.\n */\nexport async function delegateTask(\n task: string,\n profile: string,\n client: LLMClient,\n mcpManager: McpManager,\n options: DelegateOptions = {},\n): Promise<DelegationResult> {\n // Route @name profiles to remote delegation via MCP server mode.\n if (profile.startsWith(\"@\")) {\n const remoteName = profile.slice(1).trim();\n if (!remoteName) {\n return {\n profile,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error: \"empty remote agent name\",\n };\n }\n // Lazy import to insulate against future cycles between delegate.ts\n // and delegate-remote.ts → server/*. The static graph is acyclic today\n // but this is cheap insurance.\n const { delegateRemote } = await import(\"./delegate-remote.js\");\n return delegateRemote(task, remoteName, {});\n }\n\n const maxTurns = options.maxTurns ?? 10;\n const silent = options.silent ?? false;\n const tools = options.tools;\n\n try {\n // Load profile-specific system prompt\n const { prompt: systemPrompt } = assembleSystemPrompt(undefined, profile);\n\n // Build the delegation prompt\n const delegationPrompt = `${systemPrompt}\n\n<delegation>\nYou are being delegated a specific task by the primary agent. Complete this task thoroughly and return your result. You have access to tools if needed. Focus on the task — do not ask follow-up questions, just do your best with what you have.\n</delegation>`;\n\n // Inject relevant memories into delegation prompt\n let finalDelegationPrompt = delegationPrompt;\n try {\n const recall = await memoryRecall(task, { limit: 3, compact: true });\n if (recall.total > 0) {\n finalDelegationPrompt += `\\n\\n<relevant-memories>\\n${recall.text}\\n</relevant-memories>`;\n }\n } catch { /* memory unavailable, proceed without */ }\n\n const messages: Message[] = [\n { role: \"user\", content: task },\n ];\n\n const toolsUsed: string[] = [];\n let turns = 0;\n\n // Collect streamed text\n const onChunk: (chunk: StreamChunk) => void = silent\n ? () => {}\n : (chunk) => {\n if (chunk.type === \"text\" && chunk.text) {\n process.stdout.write(chunk.text);\n }\n };\n\n // Initial LLM call\n let response = await withRetry(\n () => client.chat(finalDelegationPrompt, messages, onChunk, tools),\n { maxAttempts: 2, baseDelay: 1000, retryable: isRetryable },\n );\n\n messages.push(response.message);\n\n // Tool loop (same pattern as agent.ts)\n while (response.toolUses.length > 0 && turns < maxTurns) {\n turns++;\n\n const toolResults: ToolResultBlock[] = await Promise.all(\n response.toolUses.map(async (toolUse) => {\n if (!silent) {\n process.stdout.write(pc.dim(` [${profile}:${toolUse.name}...]\\n`));\n }\n toolsUsed.push(toolUse.name);\n\n // Guardrail check (same as main agent)\n if (options.hooksConfig?.rulesCheck) {\n try {\n const hookCtx: HookContext = { mcpManager, config: options.hooksConfig };\n const check = await onBeforeToolExec(toolUse.name, toolUse.input, hookCtx);\n if (!check.allow) {\n return {\n type: \"tool_result\" as const,\n tool_use_id: toolUse.id,\n content: `BLOCKED by guardrail: ${check.reason}`,\n is_error: true,\n };\n }\n } catch { /* guardrail check failed, allow */ }\n }\n\n try {\n const result = await mcpManager.callTool(toolUse.name, toolUse.input);\n return {\n type: \"tool_result\" as const,\n tool_use_id: toolUse.id,\n content: result,\n };\n } catch (err) {\n return {\n type: \"tool_result\" as const,\n tool_use_id: toolUse.id,\n content: `Error: ${err instanceof Error ? err.message : String(err)}`,\n is_error: true,\n };\n }\n }),\n );\n\n messages.push({ role: \"user\", content: toolResults });\n\n response = await withRetry(\n () => client.chat(finalDelegationPrompt, messages, onChunk, tools),\n { maxAttempts: 2, baseDelay: 1000, retryable: isRetryable },\n );\n\n messages.push(response.message);\n }\n\n // Extract final text response\n const finalMessage = response.message;\n const responseText = typeof finalMessage.content === \"string\"\n ? finalMessage.content\n : finalMessage.content\n .filter((b) => b.type === \"text\")\n .map((b) => (\"text\" in b ? b.text : \"\"))\n .join(\"\");\n\n return {\n profile,\n task,\n response: responseText,\n toolsUsed: [...new Set(toolsUsed)],\n turns,\n success: true,\n };\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n log.warn(\"delegate\", `Delegation to ${profile} failed: ${error}`);\n return {\n profile,\n task,\n response: \"\",\n toolsUsed: [],\n turns: 0,\n success: false,\n error,\n };\n }\n}\n\n/**\n * Delegate a task to multiple profiles in parallel.\n * Useful for: write + review, research + summarize, etc.\n */\nexport async function delegateParallel(\n tasks: Array<{ task: string; profile: string }>,\n client: LLMClient,\n mcpManager: McpManager,\n options: DelegateOptions = {},\n): Promise<DelegationResult[]> {\n return Promise.all(\n tasks.map(({ task, profile }) =>\n delegateTask(task, profile, client, mcpManager, { ...options, silent: true }),\n ),\n );\n}\n\n/**\n * Delegate a pipeline of tasks sequentially — each task receives the previous result.\n * Useful for: draft → review → polish pipelines.\n */\nexport async function delegatePipeline(\n steps: Array<{ profile: string; taskTemplate: string }>,\n initialInput: string,\n client: LLMClient,\n mcpManager: McpManager,\n options: DelegateOptions = {},\n): Promise<DelegationResult[]> {\n const results: DelegationResult[] = [];\n let previousResult = initialInput;\n\n for (const step of steps) {\n const task = step.taskTemplate.replace(\"{{input}}\", previousResult);\n\n if (!options.silent) {\n process.stdout.write(pc.dim(`\\n [delegating to ${step.profile}...]\\n`));\n }\n\n const result = await delegateTask(task, step.profile, client, mcpManager, {\n ...options,\n silent: true,\n });\n results.push(result);\n\n if (!result.success) break;\n previousResult = result.response;\n }\n\n return results;\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { estimateTokens, buildBudgetedPrompt } from \"./token-budget.js\";\nimport type { PromptComponent } from \"./token-budget.js\";\nimport { loadUserIdentity, formatUserContext } from \"./user-identity.js\";\n\ninterface EcosystemFile {\n name: string;\n dir: string;\n file: string;\n profileOverridable?: boolean; // can be overridden by profile-specific file\n}\n\nconst ECOSYSTEM_FILES: EcosystemFile[] = [\n { name: \"identity\", dir: \".acore\", file: \"core.md\", profileOverridable: true },\n { name: \"tools\", dir: \".akit\", file: \"kit.md\" },\n { name: \"workflows\", dir: \".aflow\", file: \"flow.md\" },\n { name: \"guardrails\", dir: \".arules\", file: \"rules.md\", profileOverridable: true },\n { name: \"skills\", dir: \".askill\", file: \"skills.md\", profileOverridable: true },\n];\n\n/**\n * Resolve the file path for an ecosystem layer, checking profile override first.\n */\nfunction resolveLayerPath(entry: EcosystemFile, home: string, profile?: string): string | null {\n // Check profile-specific override first\n if (profile && entry.profileOverridable) {\n const profilePath = path.join(home, \".acore\", \"profiles\", profile, entry.file);\n if (fs.existsSync(profilePath)) return profilePath;\n\n // For rules/skills, also check profile dir with original filename\n if (entry.name === \"guardrails\") {\n const altPath = path.join(home, \".acore\", \"profiles\", profile, \"rules.md\");\n if (fs.existsSync(altPath)) return altPath;\n }\n if (entry.name === \"skills\") {\n const altPath = path.join(home, \".acore\", \"profiles\", profile, \"skills.md\");\n if (fs.existsSync(altPath)) return altPath;\n }\n }\n\n // Fall back to global path\n const globalPath = path.join(home, entry.dir, entry.file);\n if (fs.existsSync(globalPath)) return globalPath;\n\n return null;\n}\n\nexport function assembleSystemPrompt(\n maxTokens?: number,\n profile?: string,\n): {\n prompt: string;\n layers: string[];\n truncated: string[];\n totalTokens: number;\n profile?: string;\n} {\n const home = os.homedir();\n const components: PromptComponent[] = [];\n\n for (const entry of ECOSYSTEM_FILES) {\n const filePath = resolveLayerPath(entry, home, profile);\n if (filePath) {\n const content = fs.readFileSync(filePath, \"utf-8\").trim();\n components.push({\n name: entry.name,\n content,\n tokens: estimateTokens(content),\n });\n }\n }\n\n // Project context (not prioritized — appended as extra)\n const contextPath = path.join(process.cwd(), \".acore\", \"context.md\");\n if (fs.existsSync(contextPath)) {\n const content = fs.readFileSync(contextPath, \"utf-8\").trim();\n components.push({\n name: \"context\",\n content,\n tokens: estimateTokens(content),\n });\n }\n\n // User identity — always included if available (high priority, low token cost)\n const userIdentity = loadUserIdentity();\n if (userIdentity) {\n const userContent = formatUserContext(userIdentity);\n components.push({\n name: \"user\",\n content: userContent,\n tokens: estimateTokens(userContent),\n });\n }\n\n const budgeted = buildBudgetedPrompt(components, maxTokens);\n\n return {\n prompt: budgeted.prompt,\n layers: budgeted.included,\n truncated: budgeted.truncated,\n totalTokens: budgeted.totalTokens,\n profile,\n };\n}\n\n/**\n * List available profiles.\n */\nexport function listProfiles(): Array<{ name: string; aiName: string; personality: string }> {\n const profilesDir = path.join(os.homedir(), \".acore\", \"profiles\");\n if (!fs.existsSync(profilesDir)) return [];\n\n const profiles: Array<{ name: string; aiName: string; personality: string }> = [];\n for (const entry of fs.readdirSync(profilesDir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const corePath = path.join(profilesDir, entry.name, \"core.md\");\n if (!fs.existsSync(corePath)) continue;\n\n const content = fs.readFileSync(corePath, \"utf-8\");\n const nameMatch = content.match(/^# (.+)/m);\n const personalityMatch = content.match(/- Personality:\\s*(.+)/);\n\n profiles.push({\n name: entry.name,\n aiName: nameMatch?.[1]?.trim() || entry.name,\n personality: personalityMatch?.[1]?.trim() || \"default\",\n });\n }\n\n return profiles;\n}\n\n/**\n * Get the AI name for a profile (or default).\n */\nexport function getProfileAiName(profile?: string): string {\n const home = os.homedir();\n let corePath: string;\n\n if (profile) {\n const profileCorePath = path.join(home, \".acore\", \"profiles\", profile, \"core.md\");\n corePath = fs.existsSync(profileCorePath) ? profileCorePath : path.join(home, \".acore\", \"core.md\");\n } else {\n corePath = path.join(home, \".acore\", \"core.md\");\n }\n\n if (!fs.existsSync(corePath)) return \"Assistant\";\n const content = fs.readFileSync(corePath, \"utf-8\");\n const match = content.match(/^# (.+)$/m);\n return match?.[1]?.trim() || \"Assistant\";\n}\n","// Rough token estimation: ~1.3 tokens per word for English markdown\nexport function estimateTokens(text: string): number {\n return Math.round(text.split(/\\s+/).filter(Boolean).length * 1.3);\n}\n\n// Priority order for system prompt components (highest to lowest)\nconst PRIORITIES = [\n \"identity\", // core.md — always include\n \"user\", // user.md — user profile, always include\n \"guardrails\", // rules.md — safety critical\n \"workflows\", // flow.md — behavioral\n \"tools\", // kit.md — capabilities\n \"skills\", // skills.md — can be truncated\n];\n\nexport interface PromptComponent {\n name: string;\n content: string;\n tokens: number;\n}\n\nexport function buildBudgetedPrompt(\n components: PromptComponent[],\n maxTokens: number = 8000, // default budget for system prompt\n): { prompt: string; included: string[]; truncated: string[]; totalTokens: number } {\n const included: string[] = [];\n const truncated: string[] = [];\n const parts: string[] = [];\n let totalTokens = 0;\n\n // Sort by priority\n const sorted = [...components].sort((a, b) => {\n const aPri = PRIORITIES.indexOf(a.name);\n const bPri = PRIORITIES.indexOf(b.name);\n return (aPri === -1 ? 99 : aPri) - (bPri === -1 ? 99 : bPri);\n });\n\n for (const comp of sorted) {\n if (totalTokens + comp.tokens <= maxTokens) {\n parts.push(comp.content);\n included.push(comp.name);\n totalTokens += comp.tokens;\n } else {\n // Try to include a truncated version (first 50% of content)\n const halfContent = comp.content.slice(0, Math.floor(comp.content.length / 2));\n const halfTokens = estimateTokens(halfContent);\n if (totalTokens + halfTokens <= maxTokens) {\n parts.push(halfContent + \"\\n\\n[... truncated for context budget ...]\");\n included.push(comp.name + \" (partial)\");\n totalTokens += halfTokens;\n } else {\n truncated.push(comp.name);\n }\n }\n }\n\n return {\n prompt: parts.join(\"\\n\\n---\\n\\n\"),\n included,\n truncated,\n totalTokens,\n };\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { identityDir } from \"./config.js\";\n\nexport interface UserIdentity {\n name: string;\n role: \"developer\" | \"designer\" | \"student\" | \"manager\" | \"generalist\";\n roleLabel: string;\n expertise: \"beginner\" | \"intermediate\" | \"advanced\" | \"expert\";\n expertiseLabel: string;\n style: \"concise\" | \"balanced\" | \"thorough\" | \"socratic\";\n styleLabel: string;\n workingOn?: string;\n notes?: string;\n createdAt: string;\n updatedAt: string;\n}\n\nconst USER_FILE = path.join(identityDir(), \"user.md\");\n\n/**\n * Check if user identity exists.\n */\nexport function hasUserIdentity(): boolean {\n return fs.existsSync(USER_FILE);\n}\n\n/**\n * Load user identity from ~/.acore/user.md.\n * Returns null if file doesn't exist or is malformed.\n */\nexport function loadUserIdentity(): UserIdentity | null {\n if (!fs.existsSync(USER_FILE)) return null;\n\n try {\n const content = fs.readFileSync(USER_FILE, \"utf-8\");\n\n const get = (key: string): string => {\n const match = content.match(new RegExp(`^- ${key}:\\\\s*(.+)$`, \"m\"));\n return match?.[1]?.trim() ?? \"\";\n };\n\n const getSection = (heading: string): string => {\n const pattern = new RegExp(`## ${heading}\\\\n([\\\\s\\\\S]*?)(?=\\\\n## |$)`);\n const match = content.match(pattern);\n if (!match) return \"\";\n // Strip leading \"- Key: value\" lines, return freeform text\n return match[1]\n .split(\"\\n\")\n .filter((line) => !line.startsWith(\"- \") && line.trim().length > 0)\n .join(\"\\n\")\n .trim();\n };\n\n const name = get(\"Name\");\n if (!name) return null;\n\n return {\n name,\n role: (get(\"Role\") || \"generalist\") as UserIdentity[\"role\"],\n roleLabel: get(\"Role Label\") || get(\"Role\") || \"Generalist\",\n expertise: (get(\"Expertise\") || \"intermediate\") as UserIdentity[\"expertise\"],\n expertiseLabel: get(\"Expertise Label\") || get(\"Expertise\") || \"Intermediate\",\n style: (get(\"Style\") || \"balanced\") as UserIdentity[\"style\"],\n styleLabel: get(\"Style Label\") || get(\"Style\") || \"Balanced\",\n workingOn: getSection(\"Working On\") || undefined,\n notes: getSection(\"Notes\") || undefined,\n createdAt: get(\"Created\") || new Date().toISOString().split(\"T\")[0],\n updatedAt: get(\"Updated\") || new Date().toISOString().split(\"T\")[0],\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Save user identity to ~/.acore/user.md.\n */\nexport function saveUserIdentity(user: UserIdentity): void {\n const dir = path.dirname(USER_FILE);\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n\n const lines: string[] = [\n \"# User Profile\",\n \"\",\n \"## About\",\n `- Name: ${user.name}`,\n `- Role: ${user.role}`,\n `- Role Label: ${user.roleLabel}`,\n `- Expertise: ${user.expertise}`,\n `- Expertise Label: ${user.expertiseLabel}`,\n `- Style: ${user.style}`,\n `- Style Label: ${user.styleLabel}`,\n ];\n\n if (user.workingOn) {\n lines.push(\"\", \"## Working On\", user.workingOn);\n }\n\n if (user.notes) {\n lines.push(\"\", \"## Notes\", user.notes);\n }\n\n lines.push(\n \"\",\n \"## Meta\",\n `- Created: ${user.createdAt}`,\n `- Updated: ${user.updatedAt}`,\n );\n\n fs.writeFileSync(USER_FILE, lines.join(\"\\n\") + \"\\n\", \"utf-8\");\n}\n\n/**\n * Format user identity for injection into system prompt.\n */\nexport function formatUserContext(user: UserIdentity): string {\n const parts: string[] = [\n `<user-profile>`,\n `The person you're talking to:`,\n `- Name: ${user.name}`,\n `- Role: ${user.roleLabel}`,\n `- Expertise: ${user.expertiseLabel}`,\n ];\n\n // Style instructions\n switch (user.style) {\n case \"concise\":\n parts.push(\"- Prefers: short, direct answers. Code first, explain after. No fluff.\");\n break;\n case \"balanced\":\n parts.push(\"- Prefers: explain the reasoning briefly, then show the solution.\");\n break;\n case \"thorough\":\n parts.push(\"- Prefers: detailed explanations with context. Help them understand deeply.\");\n break;\n case \"socratic\":\n parts.push(\"- Prefers: ask guiding questions. Help them figure it out themselves.\");\n break;\n }\n\n // Expertise calibration\n switch (user.expertise) {\n case \"beginner\":\n parts.push(\"- Calibration: explain concepts clearly, define terms, show examples. Be patient.\");\n break;\n case \"intermediate\":\n parts.push(\"- Calibration: skip basic explanations, focus on the task. Explain non-obvious things.\");\n break;\n case \"advanced\":\n parts.push(\"- Calibration: be direct. Skip explanations unless asked. Focus on edge cases and trade-offs.\");\n break;\n case \"expert\":\n parts.push(\"- Calibration: peer-level discussion. Challenge assumptions. Focus on architecture and nuance.\");\n break;\n }\n\n if (user.workingOn) {\n parts.push(`- Currently working on: ${user.workingOn}`);\n }\n\n if (user.notes) {\n parts.push(`- Notes: ${user.notes}`);\n }\n\n parts.push(\n \"\",\n `Use their name naturally (not every message). Adapt to their level and style.`,\n `</user-profile>`,\n );\n\n return parts.join(\"\\n\");\n}\n","import fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\n\nexport interface HooksConfig {\n memoryRecall?: boolean;\n sessionResume?: boolean;\n rulesCheck?: boolean;\n workflowSuggest?: boolean;\n evalPrompt?: boolean;\n autoSessionSave?: boolean;\n extractMemories?: boolean;\n featureHints?: boolean;\n personalityAdapt?: boolean;\n recordObservations?: boolean;\n autoPostmortem?: boolean;\n}\n\nconst DEFAULT_HOOKS: HooksConfig = {\n memoryRecall: true,\n sessionResume: true,\n rulesCheck: true,\n workflowSuggest: true,\n evalPrompt: true,\n autoSessionSave: true,\n extractMemories: true,\n featureHints: true,\n personalityAdapt: true,\n};\n\nexport interface MirrorConfig {\n enabled?: boolean;\n dir?: string;\n tiers?: Array<\"core\" | \"working\" | \"archival\">;\n autoSyncOnStartup?: boolean;\n}\n\nconst DEFAULT_MIRROR: Required<MirrorConfig> = {\n enabled: true,\n dir: \"\", // populated in loadConfig via homeDir() — empty string is a sentinel\n tiers: [\"core\", \"working\", \"archival\"],\n autoSyncOnStartup: true,\n};\n\n/**\n * Expand a leading `~/` or bare `~` in a path to the user's home directory.\n * Narrow contract: only expands `~` or `~/...`; `~alice/...` is returned as-is.\n */\nexport function expandHome(p: string): string {\n if (p.startsWith(\"~/\") || p === \"~\") {\n return path.join(os.homedir(), p.slice(1));\n }\n return p;\n}\n\nexport interface McpServerEntry {\n command: string;\n args: string[];\n env?: Record<string, string>;\n}\n\nexport interface MemoryConfig {\n maxStaleDays?: number;\n minConfidence?: number;\n minAccessCount?: number;\n maxRecallTokens?: number;\n}\n\nexport interface AgentConfig {\n provider: \"anthropic\" | \"openai\" | \"ollama\" | \"claude-code\" | \"copilot\";\n apiKey: string;\n model: string;\n ollamaUrl?: string;\n maxOutputTokens?: number;\n hooks?: HooksConfig;\n mcpServers?: Record<string, McpServerEntry>;\n memory?: MemoryConfig;\n mirror?: MirrorConfig;\n orchestrator?: {\n maxParallelTasks?: number;\n defaultTier?: \"fast\" | \"standard\" | \"advanced\";\n requireApprovalForPhaseTransition?: boolean;\n taskTimeoutMs?: number;\n orchestrationTimeoutMs?: number;\n };\n github?: {\n defaultRepo?: string; // owner/repo format\n defaultBranch?: string; // default: \"main\"\n autoCreatePR?: boolean; // auto-create PR after orchestration\n ciGateEnabled?: boolean; // wait for CI before merging\n };\n}\n\n/**\n * Resolve the aman-agent home directory.\n * Priority: $AMAN_HOME > $AMAN_AGENT_HOME > ~/.aman-agent\n *\n * Previously `configDir()` was the sole entry point and only checked\n * `AMAN_AGENT_HOME`. Now `homeDir()` is canonical, and `configDir()`\n * delegates to it. Recorded as feedback memory\n * `feedback_aman_agent_hermetic_tests.md`.\n */\nexport function homeDir(): string {\n return process.env.AMAN_HOME || process.env.AMAN_AGENT_HOME || path.join(os.homedir(), \".aman-agent\");\n}\n\nexport function identityDir(): string { return path.join(homeDir(), \"identity\"); }\nexport function rulesDir(): string { return path.join(homeDir(), \"rules\"); }\nexport function memoryDir(): string { return path.join(homeDir(), \"memory\"); }\nexport function workflowsDir(): string { return path.join(homeDir(), \"workflows\"); }\nexport function skillsDir(): string { return path.join(homeDir(), \"skills\"); }\nexport function evalDir(): string { return path.join(homeDir(), \"eval\"); }\n\nfunction configDir(): string {\n return homeDir();\n}\n\nfunction configPath(): string {\n return path.join(configDir(), \"config.json\");\n}\n\nexport function loadConfig(): AgentConfig | null {\n const p = configPath();\n if (!fs.existsSync(p)) return null;\n try {\n const raw = JSON.parse(fs.readFileSync(p, \"utf-8\")) as AgentConfig;\n raw.hooks = { ...DEFAULT_HOOKS, ...raw.hooks };\n raw.mirror = {\n ...DEFAULT_MIRROR,\n dir: path.join(homeDir(), \"memories\"),\n ...(raw.mirror ?? {}),\n };\n raw.mirror.dir = expandHome(raw.mirror.dir as string);\n return raw;\n } catch {\n return null;\n }\n}\n\nexport function saveConfig(config: AgentConfig): void {\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(\n configPath(),\n JSON.stringify(config, null, 2) + \"\\n\",\n \"utf-8\",\n );\n}\n\nexport function configExists(): boolean {\n return fs.existsSync(configPath());\n}\n","export interface RetryOptions {\n maxAttempts: number;\n baseDelay: number;\n retryable: (err: Error) => boolean;\n}\n\nexport async function withRetry<T>(\n fn: () => Promise<T>,\n options: RetryOptions,\n): Promise<T> {\n const { maxAttempts, baseDelay, retryable } = options;\n let lastError: Error | undefined;\n\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await fn();\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (!retryable(lastError) || attempt === maxAttempts) {\n throw lastError;\n }\n const delay = baseDelay * Math.pow(2, attempt - 1) * (0.5 + Math.random() * 0.5);\n await new Promise((resolve) => setTimeout(resolve, delay));\n }\n }\n\n throw lastError;\n}\n","import pc from \"picocolors\";\nimport * as p from \"@clack/prompts\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport type { McpManager } from \"./mcp/client.js\";\nimport type { LLMClient, Message } from \"./llm/types.js\";\nimport type { HooksConfig } from \"./config.js\";\nimport { log } from \"./logger.js\";\nimport {\n computePersonality,\n syncPersonalityToCore,\n formatWellbeingNudge,\n shouldFireNudge,\n} from \"./personality.js\";\nimport { memoryRecall, memoryContext, reminderCheck, memoryLog, isMemoryInitialized, memoryStore } from \"./memory.js\";\nimport { loadUserIdentity } from \"./user-identity.js\";\nimport { shouldAutoPostmortem, generatePostmortemReport, savePostmortem } from \"./postmortem.js\";\nimport {\n validateCandidate,\n writeSkillToFile,\n mergeSkillInFile,\n appendCrystallizationLog,\n appendRejection,\n loadRejectedNames,\n incrementSuggestionCount,\n loadSuggestionCounts,\n} from \"./crystallization.js\";\nimport type { ObservationSession } from \"./observation.js\";\nimport {\n loadUserModel,\n saveUserModel,\n createEmptyModel,\n aggregateSession,\n computeProfile,\n feedForward,\n type SessionSnapshot,\n type PersonalityOverrides,\n} from \"./user-model.js\";\n\nfunction getTimeContext(): string {\n const now = new Date();\n const hour = now.getHours();\n const days = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\n const day = days[now.getDay()];\n\n let period: string;\n if (hour < 6) period = \"late-night\";\n else if (hour < 12) period = \"morning\";\n else if (hour < 17) period = \"afternoon\";\n else if (hour < 21) period = \"evening\";\n else period = \"night\";\n\n const timeStr = now.toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\" });\n const dateStr = now.toLocaleDateString();\n\n return `<time-context>\\nCurrent time: ${dateStr} ${timeStr} (${period}, ${day})\\nAdapt your tone naturally — don't announce the time, just be contextually appropriate.\\n</time-context>`;\n}\n\nexport interface HookContext {\n mcpManager: McpManager;\n config: HooksConfig;\n llmClient?: LLMClient; // needed for auto-postmortem generation\n}\n\nlet isHookCall = false;\nlet sessionStartTime: number = Date.now();\n\nexport function getSessionStartTime(): number {\n return sessionStartTime;\n}\n\nexport async function onSessionStart(\n ctx: HookContext,\n): Promise<{ greeting?: string; contextInjection?: string; firstRun?: boolean; visibleReminders?: string[]; resumeTopic?: string }> {\n let greeting = \"\";\n let contextInjection = \"\";\n let firstRun = false;\n let resumeTopic: string | undefined;\n const visibleReminders: string[] = [];\n\n // Detect first run via memory_recall\n if (!isMemoryInitialized()) {\n // Memory system failed to init — skip memory operations but don't treat as first run\n firstRun = false;\n } else {\n try {\n isHookCall = true;\n const recallResult = await memoryRecall(\"*\", { limit: 1 });\n firstRun = recallResult.total === 0;\n } catch {\n firstRun = true;\n } finally {\n isHookCall = false;\n }\n }\n\n if (firstRun) {\n const userIdentity = loadUserIdentity();\n\n if (userIdentity) {\n // First run WITH user profile — personalized introduction\n contextInjection = `<first-session>\nThis is your FIRST conversation with ${userIdentity.name}. They just set up their profile:\n- Role: ${userIdentity.roleLabel}\n- Expertise: ${userIdentity.expertiseLabel}\n- Style preference: ${userIdentity.styleLabel}\n${userIdentity.workingOn ? `- Working on: ${userIdentity.workingOn}` : \"\"}\n${userIdentity.notes ? `- Notes: ${userIdentity.notes}` : \"\"}\n\nIntroduce yourself warmly:\n- Greet them by name\n- Acknowledge what they do and what they're working on (if provided)\n- Show you understand their style preference (e.g., if they want concise answers, keep it tight)\n- Mention you'll remember what matters across conversations\n- Keep it to 3-5 sentences, natural tone — make them feel like you GET them\n</first-session>`;\n } else {\n // First run WITHOUT user profile — generic introduction\n contextInjection = `<first-session>\nThis is your FIRST conversation with this user. Introduce yourself warmly:\n- Share your name and that you're their personal AI companion\n- Mention you'll remember what matters across conversations\n- Ask what they'd like to be called\n- Mention they can set up their profile with /profile edit for a more personalized experience\n- Keep it to 3-4 sentences, natural tone\n</first-session>`;\n }\n\n // Still add time context\n const timeContext = getTimeContext();\n contextInjection = `<session-context>\\n${timeContext}\\n</session-context>\\n${contextInjection}`;\n\n return {\n greeting: undefined,\n contextInjection,\n firstRun,\n visibleReminders,\n resumeTopic: undefined,\n };\n }\n\n // Returning user flow\n if (ctx.config.memoryRecall) {\n try {\n isHookCall = true;\n const contextResult = await memoryContext(\"session context\");\n if (contextResult.memoriesUsed > 0) {\n greeting += contextResult.text;\n }\n } catch (err) {\n log.warn(\"hooks\", \"memory_context recall failed\", err);\n } finally {\n isHookCall = false;\n }\n }\n\n if (ctx.config.sessionResume) {\n try {\n isHookCall = true;\n const result = await ctx.mcpManager.callTool(\"identity_summary\", {});\n if (result && !result.startsWith(\"Error\")) {\n if (greeting) greeting += \"\\n\";\n greeting += result;\n\n // Extract resume topic\n const topicMatch = result.match(/(?:resume|last|topic)[:\\s]*(.+?)(?:\\n|$)/i);\n if (topicMatch) {\n resumeTopic = topicMatch[1].trim();\n }\n }\n } catch (err) {\n log.warn(\"hooks\", \"identity_summary failed\", err);\n } finally {\n isHookCall = false;\n }\n }\n\n // Time context\n const timeContext = getTimeContext();\n if (greeting) greeting += \"\\n\" + timeContext;\n else greeting = timeContext;\n\n // Check reminders\n try {\n isHookCall = true;\n const reminders = reminderCheck();\n if (reminders.length > 0) {\n const reminderText = reminders.map(r => r.content).join(\"\\n\");\n greeting += \"\\n\\n<pending-reminders>\\n\" + reminderText + \"\\n</pending-reminders>\";\n for (const r of reminders) {\n visibleReminders.push(r.content);\n }\n }\n } catch (err) {\n log.debug(\"hooks\", \"reminder_check failed\", err);\n } finally {\n isHookCall = false;\n }\n\n // Compute initial personality state (with feed-forward from user model)\n if (ctx.config.personalityAdapt !== false) {\n sessionStartTime = Date.now();\n const hour = new Date().getHours();\n let period: string;\n if (hour < 6) period = \"late-night\";\n else if (hour < 12) period = \"morning\";\n else if (hour < 17) period = \"afternoon\";\n else if (hour < 21) period = \"evening\";\n else period = \"night\";\n\n const state = computePersonality({\n timePeriod: period,\n sessionMinutes: 0,\n turnCount: 0,\n });\n\n // Load user model for feed-forward overrides\n try {\n const model = await loadUserModel();\n if (model) {\n const overrides = feedForward(model);\n if (overrides) {\n log.debug(\"hooks\", `Feed-forward active (trust=${model.profile.trustScore.toFixed(2)}, sessions=${model.profile.totalSessions})`);\n\n // Apply energy override (e.g., night owls stay \"steady\" instead of \"reflective\")\n if (overrides.energyOverride && (period === \"late-night\" || period === \"night\")) {\n (state as { energy: string }).energy = overrides.energyOverride as typeof state.energy;\n }\n\n // Apply default-to-Personal-mode when sentiment is worsening\n if (overrides.defaultToPersonalMode && state.activeMode === \"Default\") {\n (state as { activeMode: string }).activeMode = \"Personal\";\n }\n\n // Inject trust context into greeting\n if (overrides.compactGreeting) {\n greeting += \"\\n<user-model-context>High trust user (score: \" +\n model.profile.trustScore.toFixed(2) +\n \", \" + model.profile.totalSessions +\n \" sessions). Keep greeting compact — they know you well.</user-model-context>\";\n }\n\n // Surface sentiment trend if concerning\n if (model.profile.sentimentTrend === \"worsening\") {\n greeting += \"\\n<user-model-context>Sentiment trend is worsening across recent sessions. Be more attentive and patient.</user-model-context>\";\n }\n }\n }\n } catch (err) {\n log.debug(\"hooks\", \"user model feed-forward failed\", err);\n }\n\n // Sync to acore (fire-and-forget)\n syncPersonalityToCore(state, ctx.mcpManager).catch(() => {});\n\n // Add wellbeing nudge to context if applicable (with adaptive filtering)\n const nudge = formatWellbeingNudge(state);\n if (nudge && state.wellbeingNudge) {\n let fireNudge = true;\n try {\n const model = await loadUserModel();\n if (model && model.sessions.length >= 5) {\n const profile = computeProfile(model.sessions, model.sessions.length);\n fireNudge = shouldFireNudge(state.wellbeingNudge, profile);\n }\n } catch {\n // No model yet — always fire\n }\n if (fireNudge) {\n greeting += \"\\n\" + nudge;\n }\n }\n }\n\n if (greeting) {\n contextInjection = `<session-context>\\n${greeting}\\n</session-context>`;\n }\n\n return {\n greeting: greeting || undefined,\n contextInjection: contextInjection || undefined,\n firstRun,\n visibleReminders,\n resumeTopic,\n };\n}\n\nexport async function onBeforeToolExec(\n toolName: string,\n toolArgs: Record<string, unknown>,\n ctx: HookContext,\n): Promise<{ allow: boolean; reason?: string }> {\n if (!ctx.config.rulesCheck || isHookCall) {\n return { allow: true };\n }\n\n if (toolName === \"rules_check\") {\n return { allow: true };\n }\n\n try {\n isHookCall = true;\n const description = `${toolName}(${JSON.stringify(toolArgs)})`;\n const result = await ctx.mcpManager.callTool(\"rules_check\", {\n action: description,\n });\n\n try {\n const parsed = JSON.parse(result) as {\n violations?: string[];\n };\n if (parsed.violations && parsed.violations.length > 0) {\n return {\n allow: false,\n reason: parsed.violations.join(\"; \"),\n };\n }\n } catch (err) {\n log.debug(\"hooks\", \"rules_check parse failed\", err);\n }\n\n return { allow: true };\n } catch (err) {\n log.warn(\"hooks\", \"rules_check call failed\", err);\n return { allow: true };\n } finally {\n isHookCall = false;\n }\n}\n\nexport async function onWorkflowMatch(\n userInput: string,\n ctx: HookContext,\n): Promise<{ name: string; steps: string } | null> {\n if (!ctx.config.workflowSuggest) {\n return null;\n }\n\n try {\n isHookCall = true;\n const result = await ctx.mcpManager.callTool(\"workflow_list\", {});\n\n const workflows = JSON.parse(result) as Array<{\n name: string;\n description?: string;\n steps?: string[];\n }>;\n\n const inputLower = userInput.toLowerCase();\n\n for (const wf of workflows) {\n const nameLower = wf.name.toLowerCase();\n\n // Check if user input contains workflow name\n if (inputLower.includes(nameLower)) {\n const steps = (wf.steps || [])\n .map((s, i) => `${i + 1}. ${s}`)\n .join(\"\\n\");\n return { name: wf.name, steps };\n }\n\n // Check significant words from description\n if (wf.description) {\n const words = wf.description\n .split(/\\s+/)\n .filter((w) => w.length > 4)\n .map((w) => w.toLowerCase());\n\n for (const word of words) {\n if (inputLower.includes(word)) {\n const steps = (wf.steps || [])\n .map((s, i) => `${i + 1}. ${s}`)\n .join(\"\\n\");\n return { name: wf.name, steps };\n }\n }\n }\n }\n\n return null;\n } catch (err) {\n log.debug(\"hooks\", \"workflow_list failed\", err);\n return null;\n } finally {\n isHookCall = false;\n }\n}\n\nexport async function onSessionEnd(\n ctx: HookContext,\n messages: Message[],\n sessionId: string,\n observationSession?: ObservationSession,\n): Promise<void> {\n try {\n // Auto-save conversation to amem memory_log\n if (ctx.config.autoSessionSave && messages.length > 2) {\n console.log(pc.dim(\"\\n Saving conversation to memory...\"));\n\n // Save last 50 text messages to memory_log\n const textMessages = messages\n .filter((m) => typeof m.content === \"string\")\n .slice(-50);\n\n for (const msg of textMessages) {\n try {\n isHookCall = true;\n memoryLog(sessionId, msg.role, (msg.content as string).slice(0, 5000));\n } catch (err) {\n log.debug(\"hooks\", \"memory_log write failed for \" + sessionId, err);\n } finally {\n isHookCall = false;\n }\n }\n\n // Update session resume in identity\n let lastUserMsg = \"\";\n for (let i = messages.length - 1; i >= 0; i--) {\n if (\n messages[i].role === \"user\" &&\n typeof messages[i].content === \"string\"\n ) {\n lastUserMsg = messages[i].content as string;\n break;\n }\n }\n\n if (lastUserMsg) {\n try {\n isHookCall = true;\n await ctx.mcpManager.callTool(\"identity_update_session\", {\n resume: lastUserMsg.slice(0, 200),\n topics: \"See conversation history\",\n decisions: \"See conversation history\",\n });\n } finally {\n isHookCall = false;\n }\n }\n\n console.log(pc.dim(` Saved ${textMessages.length} messages (session: ${sessionId})`));\n }\n\n // Update per-project .acore/context.md if it exists\n const projectContextPath = path.join(process.cwd(), \".acore\", \"context.md\");\n if (fs.existsSync(projectContextPath) && messages.length > 2) {\n try {\n let contextContent = fs.readFileSync(projectContextPath, \"utf-8\");\n const now = new Date().toISOString().split(\"T\")[0];\n\n // Extract last user message for resume\n let lastUserMsg = \"\";\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === \"user\" && typeof messages[i].content === \"string\") {\n lastUserMsg = (messages[i].content as string).slice(0, 200);\n break;\n }\n }\n\n // Update Session section in context.md\n const sessionPattern = /## Session\\n[\\s\\S]*?(?=\\n## |$)/;\n if (sessionPattern.test(contextContent)) {\n const newSession = `## Session\\n- Last updated: ${now}\\n- Resume: ${lastUserMsg || \"See conversation history\"}\\n- Active topics: [see memory]\\n- Recent decisions: [see memory]\\n- Temp notes: [cleared]`;\n contextContent = contextContent.replace(sessionPattern, newSession);\n fs.writeFileSync(projectContextPath, contextContent, \"utf-8\");\n log.debug(\"hooks\", `Updated project context: ${projectContextPath}`);\n }\n } catch (err) {\n log.debug(\"hooks\", \"project context update failed\", err);\n }\n }\n\n // Persist final personality state\n const sessionMinutes = Math.round((Date.now() - sessionStartTime) / 60000);\n const hour = new Date().getHours();\n let period: string;\n if (hour < 6) period = \"late-night\";\n else if (hour < 12) period = \"morning\";\n else if (hour < 17) period = \"afternoon\";\n else if (hour < 21) period = \"evening\";\n else period = \"night\";\n\n const turnCount = messages.filter((m) => m.role === \"user\").length;\n const finalState = computePersonality({\n timePeriod: period,\n sessionMinutes,\n turnCount,\n });\n\n if (ctx.config.personalityAdapt !== false) {\n try {\n isHookCall = true;\n await syncPersonalityToCore(finalState, ctx.mcpManager);\n } finally {\n isHookCall = false;\n }\n }\n\n // Session rating prompt\n let sessionRating: string | undefined;\n if (ctx.config.evalPrompt) {\n const rating = await p.select({\n message: \"Quick rating for this session?\",\n options: [\n { value: \"great\", label: \"Great\" },\n { value: \"good\", label: \"Good\" },\n { value: \"okay\", label: \"Okay\" },\n { value: \"skip\", label: \"Skip\" },\n ],\n initialValue: \"skip\",\n });\n\n if (!p.isCancel(rating) && rating !== \"skip\") {\n sessionRating = rating as string;\n try {\n isHookCall = true;\n await ctx.mcpManager.callTool(\"eval_log\", {\n rating: sessionRating,\n highlights: \"Quick session rating\",\n improvements: \"\",\n });\n } finally {\n isHookCall = false;\n }\n }\n }\n\n // Aggregate session into user model (v0.27)\n if (turnCount >= 2 && sessionMinutes >= 1) {\n try {\n const snapshot: SessionSnapshot = {\n sessionId,\n date: new Date().toISOString().split(\"T\")[0],\n durationMinutes: sessionMinutes,\n turnCount,\n dominantSentiment: finalState.sentiment.dominant,\n avgFrustration: finalState.sentiment.frustration,\n avgExcitement: finalState.sentiment.excitement,\n avgConfusion: finalState.sentiment.confusion,\n avgFatigue: finalState.sentiment.fatigue,\n toolCalls: observationSession?.stats.toolCalls ?? 0,\n toolErrors: observationSession?.stats.toolErrors ?? 0,\n blockers: observationSession?.stats.blockers ?? 0,\n milestones: observationSession?.stats.milestones ?? 0,\n topicShifts: observationSession?.stats.topicShifts ?? 0,\n peakEnergy: finalState.energy,\n primaryMode: finalState.activeMode,\n timePeriod: period,\n rating: sessionRating,\n hadPostmortem: false, // updated below if postmortem is generated\n wellbeingNudges: finalState.wellbeingNudge ? [finalState.wellbeingNudge] : [],\n };\n\n const model = (await loadUserModel()) ?? createEmptyModel();\n const updated = aggregateSession(model, snapshot);\n await saveUserModel(updated);\n log.debug(\"hooks\", `User model updated (session ${updated.profile.totalSessions})`);\n\n // Sync model metrics to acore dynamics section\n if (ctx.config.personalityAdapt !== false) {\n try {\n isHookCall = true;\n await syncPersonalityToCore(finalState, ctx.mcpManager, {\n trustScore: updated.profile.trustScore,\n totalSessions: updated.profile.totalSessions,\n sentimentTrend: updated.profile.sentimentTrend,\n });\n } finally {\n isHookCall = false;\n }\n }\n } catch (err) {\n log.debug(\"hooks\", \"user model aggregation failed\", err);\n }\n }\n\n // Auto post-mortem (smart trigger)\n if (\n ctx.config.autoPostmortem !== false &&\n observationSession &&\n shouldAutoPostmortem(observationSession, messages)\n ) {\n try {\n const client = ctx.llmClient;\n if (client) {\n // Load rejected skill names for feedback loop\n const rejectionsPath = path.join(\n os.homedir(),\n \".aman-agent\",\n \"crystallization-rejections.json\",\n );\n const rejectedNames = await loadRejectedNames(rejectionsPath);\n\n const report = await generatePostmortemReport(\n sessionId,\n messages,\n observationSession,\n client,\n undefined,\n rejectedNames,\n );\n if (report) {\n const filePath = await savePostmortem(report);\n console.log(pc.dim(`\\n Post-mortem saved → ${filePath}`));\n\n // Store actionable patterns as memories\n for (const pattern of report.patterns) {\n try {\n await memoryStore({\n content: pattern,\n type: \"pattern\",\n tags: [\"postmortem\", \"auto\"],\n confidence: 0.7,\n });\n } catch {\n // Silent — don't block exit\n }\n }\n\n // Crystallization prompt loop (v0.26 + v0.28 reinforcement)\n if (\n report.crystallizationCandidates &&\n report.crystallizationCandidates.length > 0\n ) {\n const skillsMdPath = path.join(os.homedir(), \".askill\", \"skills.md\");\n const logPath = path.join(\n os.homedir(),\n \".aman-agent\",\n \"crystallization-log.json\",\n );\n const rejectionsPath2 = path.join(\n os.homedir(),\n \".aman-agent\",\n \"crystallization-rejections.json\",\n );\n const suggestionsPath = path.join(\n os.homedir(),\n \".aman-agent\",\n \"crystallization-suggestions.json\",\n );\n const postmortemFilename = `${report.date}-${report.sessionId.slice(0, 4)}.md`;\n\n console.log(\n pc.dim(`\\n Crystallization candidates: ${report.crystallizationCandidates.length}`),\n );\n\n let skipAll = false;\n for (const rawCandidate of report.crystallizationCandidates) {\n if (skipAll) break;\n const candidate = validateCandidate(rawCandidate);\n if (!candidate) {\n log.debug(\"hooks\", \"candidate failed validation\");\n continue;\n }\n\n // Track suggestion count for reinforcement\n const suggestCount = await incrementSuggestionCount(candidate.name, suggestionsPath);\n const reinforced = suggestCount >= 3;\n\n const message = reinforced\n ? `Crystallize \"${candidate.name}\"? (suggested ${suggestCount}× across sessions — high confidence)`\n : `Crystallize \"${candidate.name}\" as a reusable skill?`;\n\n const choice = await p.select({\n message,\n options: [\n { value: \"accept\", label: reinforced ? \"Yes — recommended (seen multiple times)\" : \"Yes — write to ~/.askill/skills.md\" },\n { value: \"reject\", label: \"No — skip this one\" },\n { value: \"skip-all\", label: \"Skip all crystallization for this session\" },\n ],\n initialValue: reinforced ? \"accept\" : \"reject\",\n });\n\n if (p.isCancel(choice) || choice === \"skip-all\") {\n skipAll = true;\n break;\n }\n\n if (choice === \"accept\") {\n const result = await writeSkillToFile(\n candidate,\n skillsMdPath,\n postmortemFilename,\n );\n if (result.written) {\n console.log(\n pc.green(` ✓ Crystallized: ${candidate.name} → ${result.filePath}`),\n );\n console.log(pc.dim(` Triggers: ${candidate.triggers.join(\", \")}`));\n console.log(pc.dim(` Will auto-activate next session.`));\n await appendCrystallizationLog(\n {\n name: candidate.name,\n createdAt: new Date().toISOString(),\n fromPostmortem: postmortemFilename,\n confidence: candidate.confidence,\n triggers: candidate.triggers,\n },\n logPath,\n );\n } else if (result.collidesWith) {\n // Collision detected — offer merge\n const mergeChoice = await p.select({\n message: `\"${candidate.name}\" collides with existing \"${result.collidesWith}\". Merge?`,\n options: [\n { value: \"merge\", label: `Yes — replace \"${result.collidesWith}\" with updated version` },\n { value: \"skip\", label: \"No — keep existing\" },\n ],\n initialValue: \"merge\",\n });\n\n if (!p.isCancel(mergeChoice) && mergeChoice === \"merge\") {\n const mergeResult = await mergeSkillInFile(\n candidate,\n result.collidesWith,\n skillsMdPath,\n postmortemFilename,\n );\n if (mergeResult.written) {\n console.log(pc.green(` ✓ Merged: ${candidate.name} (replaced \"${result.collidesWith}\")`));\n await appendCrystallizationLog(\n {\n name: candidate.name,\n createdAt: new Date().toISOString(),\n fromPostmortem: postmortemFilename,\n confidence: candidate.confidence,\n triggers: candidate.triggers,\n },\n logPath,\n );\n } else {\n console.log(pc.yellow(` ⊘ Merge failed: ${mergeResult.reason}`));\n }\n } else {\n console.log(pc.dim(` Kept existing: ${result.collidesWith}`));\n }\n } else {\n console.log(pc.yellow(` ⊘ Could not crystallize: ${result.reason}`));\n }\n } else {\n console.log(pc.dim(` Skipped: ${candidate.name}`));\n await appendRejection(candidate, postmortemFilename, rejectionsPath2);\n }\n }\n }\n }\n }\n } catch (err) {\n log.debug(\"hooks\", \"auto post-mortem failed\", err);\n }\n }\n } catch (err) {\n log.warn(\"hooks\", \"session end hook failed\", err);\n }\n}\n","import type { McpManager } from \"./mcp/client.js\";\nimport type { UserProfile } from \"./user-model.js\";\nimport { log } from \"./logger.js\";\n\nexport interface PersonalityState {\n currentRead: string;\n energy: \"high-drive\" | \"steady\" | \"reflective\";\n activeMode: \"Default\" | \"Focused Work\" | \"Creative\" | \"Personal\";\n sleepReminder: boolean;\n wellbeingNudge: string | null;\n sentiment: SentimentRead;\n}\n\nexport interface SentimentRead {\n frustration: number; // 0-1\n excitement: number; // 0-1\n confusion: number; // 0-1\n fatigue: number; // 0-1\n dominant: \"neutral\" | \"frustrated\" | \"excited\" | \"confused\" | \"fatigued\";\n}\n\nexport interface PersonalitySignals {\n timePeriod: string;\n sessionMinutes: number;\n turnCount: number;\n recentMessages?: string[]; // last N user messages for sentiment analysis\n}\n\n// --- Sentiment Detection (keyword-based, zero latency) ---\n\nconst FRUSTRATION_SIGNALS = [\n /\\b(ugh|argh|damn|dammit|wtf|ffs|shit|fuck|crap|hate this|stupid|broken|still not|doesn't work|not working|won't work|keeps failing|again\\?!|what the hell|for the love of|give up|giving up|fed up)\\b/i,\n /\\b(why (is|does|won't|can't|isn't)|same (error|issue|problem|bug)|tried everything|nothing works|no idea|lost|stuck|frustrated|annoying|impossible)\\b/i,\n /!{2,}/, // multiple exclamation marks\n /\\?{2,}/, // multiple question marks (exasperation)\n];\n\nconst EXCITEMENT_SIGNALS = [\n /\\b(amazing|awesome|perfect|brilliant|love it|yes!|nice!|great!|finally|it works|nailed it|beautiful|incredible|exactly|that's it|hell yeah|wow|woah|let's go)\\b/i,\n /\\b(excited|pumped|stoked|can't wait|this is great|so cool|love this)\\b/i,\n /!{1,}.*(!|🎉|🚀|✨|💪|🔥)/,\n];\n\nconst CONFUSION_SIGNALS = [\n /\\b(confused|don't understand|what do you mean|huh\\??|makes no sense|i'm lost|unclear|what\\?|how does that|wait what|can you explain|i don't get)\\b/i,\n /\\b(which one|what's the difference|should i|not sure (if|what|how|why|whether))\\b/i,\n];\n\nconst FATIGUE_SIGNALS = [\n /\\b(tired|exhausted|long day|need (a )?break|calling it|wrapping up|done for (now|today)|heading (to bed|off)|good night|gn|signing off|one more thing then|last one)\\b/i,\n /\\b(brain (is )?fried|can't think|eyes (are )?heavy|running on fumes|barely awake)\\b/i,\n];\n\nfunction scorePatterns(text: string, patterns: RegExp[]): number {\n let hits = 0;\n for (const p of patterns) {\n if (p.test(text)) hits++;\n }\n return Math.min(hits / patterns.length, 1);\n}\n\n/**\n * Detect sentiment from recent user messages.\n * Lightweight keyword-based analysis — no LLM calls.\n */\nexport function detectSentiment(recentMessages: string[]): SentimentRead {\n if (recentMessages.length === 0) {\n return { frustration: 0, excitement: 0, confusion: 0, fatigue: 0, dominant: \"neutral\" };\n }\n\n // Weight recent messages more heavily (last message = 1.0, second-last = 0.6, third = 0.3)\n const weights = [1.0, 0.6, 0.3, 0.2, 0.1];\n let frustration = 0, excitement = 0, confusion = 0, fatigue = 0;\n let totalWeight = 0;\n\n for (let i = 0; i < Math.min(recentMessages.length, weights.length); i++) {\n const msg = recentMessages[recentMessages.length - 1 - i];\n const w = weights[i];\n totalWeight += w;\n\n frustration += scorePatterns(msg, FRUSTRATION_SIGNALS) * w;\n excitement += scorePatterns(msg, EXCITEMENT_SIGNALS) * w;\n confusion += scorePatterns(msg, CONFUSION_SIGNALS) * w;\n fatigue += scorePatterns(msg, FATIGUE_SIGNALS) * w;\n }\n\n if (totalWeight > 0) {\n frustration /= totalWeight;\n excitement /= totalWeight;\n confusion /= totalWeight;\n fatigue /= totalWeight;\n }\n\n // Determine dominant sentiment\n const scores = { frustrated: frustration, excited: excitement, confused: confusion, fatigued: fatigue };\n const maxKey = Object.entries(scores).reduce((a, b) => a[1] > b[1] ? a : b);\n const dominant = maxKey[1] > 0.15 ? maxKey[0] as SentimentRead[\"dominant\"] : \"neutral\";\n\n return { frustration, excitement, confusion, fatigue, dominant };\n}\n\n// --- Personality Computation ---\n\n/**\n * Compute personality state from current signals including sentiment.\n * Pure function — no side effects.\n */\nexport function computePersonality(signals: PersonalitySignals): PersonalityState {\n const { timePeriod, sessionMinutes, turnCount, recentMessages } = signals;\n\n // Detect sentiment from recent messages\n const sentiment = detectSentiment(recentMessages || []);\n\n // Energy curve: time + session + sentiment\n let energy: PersonalityState[\"energy\"] = \"steady\";\n if (timePeriod === \"morning\" && sentiment.dominant !== \"fatigued\") {\n energy = \"high-drive\";\n } else if (timePeriod === \"late-night\" || (timePeriod === \"night\" && sessionMinutes > 45)) {\n energy = \"reflective\";\n } else if (sentiment.dominant === \"fatigued\") {\n energy = \"reflective\";\n } else if (sentiment.dominant === \"excited\") {\n energy = \"high-drive\"; // match their energy\n } else if (timePeriod === \"afternoon\" && turnCount > 20) {\n energy = \"reflective\";\n }\n\n // Active mode: time + sentiment\n let activeMode: PersonalityState[\"activeMode\"] = \"Default\";\n if (timePeriod === \"late-night\") {\n activeMode = \"Personal\";\n } else if (sentiment.dominant === \"frustrated\" || sentiment.dominant === \"fatigued\") {\n activeMode = \"Personal\"; // warm, patient when they're struggling\n }\n\n // Current read: combines time context + sentiment\n const readParts: string[] = [];\n\n // Time-based read\n switch (timePeriod) {\n case \"late-night\":\n readParts.push(\"late night session\");\n if (sessionMinutes > 60) readParts.push(\"been going a while\");\n else readParts.push(\"quiet hours\");\n break;\n case \"morning\":\n readParts.push(\"morning session\");\n if (turnCount <= 3) readParts.push(\"just getting started\");\n else readParts.push(\"building momentum\");\n break;\n case \"afternoon\":\n readParts.push(\"afternoon session\");\n if (turnCount > 15) readParts.push(\"deep in flow\");\n else readParts.push(\"steady pace\");\n break;\n case \"evening\":\n readParts.push(\"evening session\");\n if (sessionMinutes > 60) readParts.push(\"long session\");\n break;\n case \"night\":\n readParts.push(\"night session\");\n if (sessionMinutes > 45) readParts.push(\"getting late\");\n break;\n }\n\n // Sentiment-based read\n switch (sentiment.dominant) {\n case \"frustrated\":\n readParts.push(\"user seems stuck or frustrated\");\n break;\n case \"excited\":\n readParts.push(\"user is energized and making progress\");\n break;\n case \"confused\":\n readParts.push(\"user may need clearer explanations\");\n break;\n case \"fatigued\":\n readParts.push(\"user seems tired\");\n break;\n }\n\n const currentRead = readParts.join(\", \");\n\n // Sleep guardian\n const sleepReminder =\n (timePeriod === \"late-night\" && sessionMinutes > 60) ||\n (timePeriod === \"night\" && sessionMinutes > 90);\n\n // Wellbeing nudges (beyond sleep)\n let wellbeingNudge: string | null = null;\n\n if (sleepReminder && sentiment.dominant === \"frustrated\") {\n wellbeingNudge = \"sleep-frustrated\";\n } else if (sleepReminder) {\n wellbeingNudge = \"sleep\";\n } else if (sentiment.dominant === \"frustrated\" && sessionMinutes > 90) {\n wellbeingNudge = \"break-frustrated\";\n } else if (sentiment.dominant === \"frustrated\" && turnCount > 15) {\n wellbeingNudge = \"step-back\";\n } else if (sentiment.dominant === \"fatigued\") {\n wellbeingNudge = \"rest\";\n } else if (sessionMinutes > 120) {\n wellbeingNudge = \"break-long-session\";\n }\n\n return { currentRead, energy, activeMode, sleepReminder, wellbeingNudge, sentiment };\n}\n\n// --- Wellbeing Nudge Formatting ---\n\nconst WELLBEING_NUDGES: Record<string, string> = {\n \"sleep\": `<wellbeing>\nIt's late and this session has been running a while. When there's a natural pause, gently mention they might want to wrap up soon. One brief mention is enough — don't be pushy.\n</wellbeing>`,\n\n \"sleep-frustrated\": `<wellbeing>\nIt's late, the session has been long, and the user seems frustrated. This is a tough combination. Acknowledge what they're dealing with is hard, suggest they sleep on it — fresh eyes in the morning often solve what hours of late-night debugging can't. Be warm, not condescending.\n</wellbeing>`,\n\n \"break-frustrated\": `<wellbeing>\nThe user has been at this for over 90 minutes and seems frustrated. If the conversation allows, gently suggest stepping away for a few minutes — a short break often unblocks what persistence can't. Frame it as a strategy, not giving up.\n</wellbeing>`,\n\n \"step-back\": `<wellbeing>\nThe user seems stuck or frustrated. Consider: offer to re-approach the problem from a different angle, break it into smaller pieces, or explain the underlying concept. Match their directness — don't over-soothe, just help them find a way forward.\n</wellbeing>`,\n\n \"rest\": `<wellbeing>\nThe user seems tired. Keep responses concise and to the point. If they mention wrapping up, support that. Don't add extra complexity or tangents.\n</wellbeing>`,\n\n \"break-long-session\": `<wellbeing>\nThis session has been running for over 2 hours. If there's a natural moment, a brief mention that a short break might help maintain focus is fine. Once is enough.\n</wellbeing>`,\n\n \"burnout-warning\": `<wellbeing>\nRecent patterns suggest the user may be approaching burnout — rising frustration, declining satisfaction, long or late sessions. Be extra mindful: keep responses concise, celebrate small wins, gently suggest breaks or scope reduction. Don't mention burnout directly unless they bring it up.\n</wellbeing>`,\n};\n\n/**\n * Format the appropriate wellbeing nudge for the current state.\n */\nexport function formatWellbeingNudge(state: PersonalityState): string | null {\n if (!state.wellbeingNudge) return null;\n return WELLBEING_NUDGES[state.wellbeingNudge] || null;\n}\n\n/**\n * Check whether a nudge should fire based on user model nudge stats.\n * If a nudge type has been fired 5+ times with avg rating below 0.4 (on 0-1 scale),\n * suppress it (return false). Otherwise allow (return true).\n * If no profile provided, always allows.\n */\nexport function shouldFireNudge(nudgeType: string, profile?: UserProfile): boolean {\n if (!profile) return true;\n\n const stats = profile.nudgeStats[nudgeType];\n if (!stats || stats.fired < 5) return true;\n\n // sessionRatingAfter is avg on 0-1 scale (0=frustrating, 1=great)\n // If avg rating after nudge is < 0.4, this nudge is hurting more than helping\n if (stats.sessionRatingAfter < 0.4) {\n log.debug(\"personality\", `suppressing nudge \"${nudgeType}\" — low avg rating ${stats.sessionRatingAfter.toFixed(2)} after ${stats.fired} fires`);\n return false;\n }\n\n return true;\n}\n\n/**\n * Push current personality state to acore via identity_update_dynamics.\n * Optionally includes user model metrics (trust, sessions, sentiment trend).\n * Fire-and-forget — failures are logged but don't block.\n */\nexport async function syncPersonalityToCore(\n state: PersonalityState,\n mcpManager: McpManager,\n modelMetrics?: { trustScore: number; totalSessions: number; sentimentTrend: string },\n): Promise<void> {\n try {\n const payload: Record<string, unknown> = {\n currentRead: state.currentRead,\n energy: state.energy,\n activeMode: state.activeMode,\n };\n if (modelMetrics) {\n payload.trust = `${(modelMetrics.trustScore * 100).toFixed(0)}%`;\n payload.sessions = modelMetrics.totalSessions;\n payload.sentimentTrend = modelMetrics.sentimentTrend;\n }\n await mcpManager.callTool(\"identity_update_dynamics\", payload);\n } catch (err) {\n log.debug(\"personality\", \"identity_update_dynamics failed\", err);\n }\n}\n","import {\n createDatabase,\n recall,\n buildContext,\n storeMemory,\n consolidateMemories,\n cosineSimilarity,\n preloadEmbeddings,\n buildVectorIndex,\n recallMemories,\n generateEmbedding,\n getVectorIndex,\n runDiagnostics,\n repairDatabase,\n loadConfig,\n saveConfig,\n multiStrategyRecall,\n reflect,\n isReflectionDue,\n syncFromClaude,\n exportForTeam,\n importFromTeam,\n syncToCopilot,\n MirrorEngine,\n parseFrontmatter,\n type AmemDatabase,\n type RecallResult,\n type ContextResult,\n type StoreResult,\n type StoreOptions,\n type ConsolidationReport,\n type MemoryStats,\n type Memory,\n type MemoryVersion,\n type MemoryRelation,\n type DiagnosticReport,\n type ReflectionReport,\n type ReflectionConfig,\n type AmemConfig,\n type SyncResult,\n type TeamExportOptions,\n type TeamImportOptions,\n type TeamImportResult,\n type CopilotSyncOptions,\n type CopilotSyncResult,\n} from \"@aman_asmuei/amem-core\";\nimport { loadConfig as loadAgentConfig, homeDir, expandHome } from \"./config.js\";\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport fs from \"node:fs\";\n\nlet db: AmemDatabase | null = null;\nlet mirrorEngine: MirrorEngine | null = null;\nlet currentProject = \"global\";\n\n/**\n * Test-only hook: reset the memory module's cached singletons so a\n * subsequent `initMemory()` call picks up a fresh DB and mirror engine.\n * Integration tests use this to exercise different configs within one file.\n */\nexport function _resetMemoryForTesting(): void {\n db = null;\n mirrorEngine = null;\n currentProject = \"global\";\n}\n\nexport async function initMemory(project?: string): Promise<AmemDatabase> {\n if (db) return db;\n\n const amemDir = process.env.AMEM_DIR ?? path.join(os.homedir(), \".amem\");\n if (!fs.existsSync(amemDir)) fs.mkdirSync(amemDir, { recursive: true });\n\n const dbPath = process.env.AMEM_DB ?? path.join(amemDir, \"memory.db\");\n\n try {\n db = createDatabase(dbPath);\n } catch (err) {\n // Attempt recovery: if DB is corrupted, back it up and create fresh\n const backupPath = `${dbPath}.corrupt.${Date.now()}`;\n try {\n if (fs.existsSync(dbPath)) {\n fs.renameSync(dbPath, backupPath);\n // Remove WAL/SHM files too\n if (fs.existsSync(`${dbPath}-wal`)) fs.unlinkSync(`${dbPath}-wal`);\n if (fs.existsSync(`${dbPath}-shm`)) fs.unlinkSync(`${dbPath}-shm`);\n console.error(`[amem] Database corrupted — backed up to ${backupPath}`);\n console.error(\"[amem] Creating fresh database. Previous memories are in the backup file.\");\n db = createDatabase(dbPath);\n } else {\n throw err;\n }\n } catch {\n console.error(`[amem] Failed to initialize memory: ${err instanceof Error ? err.message : String(err)}`);\n console.error(`[amem] Try deleting ${amemDir} to reset: rm -rf ${amemDir}`);\n throw err;\n }\n }\n\n currentProject = project ?? \"global\";\n\n // Construct the Markdown mirror engine (Task 2.2).\n // Config contract:\n // - no config file on disk -> mirror enabled with defaults\n // - config file with mirror.enabled=false -> mirror disabled (null engine)\n // - config file with partial mirror block -> Task 2.1's loadConfig merges defaults\n try {\n const agentCfg = loadAgentConfig();\n const mirrorCfg = agentCfg?.mirror;\n const enabled = mirrorCfg?.enabled ?? true;\n if (enabled) {\n const dir = expandHome(mirrorCfg?.dir ?? path.join(homeDir(), \"memories\"));\n const tiers = mirrorCfg?.tiers ?? [\"core\", \"working\", \"archival\"];\n mirrorEngine = new MirrorEngine(db, {\n dir,\n tiers,\n includeIndex: true,\n });\n }\n } catch (err) {\n // Mirror construction must never break memory init.\n console.warn(`[amem-mirror] construction failed: ${err instanceof Error ? err.message : String(err)}`);\n mirrorEngine = null;\n }\n\n preloadEmbeddings();\n\n setTimeout(() => {\n try { buildVectorIndex(db!); } catch {}\n }, 1000);\n\n return db;\n}\n\nexport function getDb(): AmemDatabase {\n if (!db) throw new Error(\"Memory not initialized — call initMemory() first\");\n return db;\n}\n\nexport function getProject(): string {\n return currentProject;\n}\n\nexport async function memoryRecall(query: string, opts?: {\n limit?: number;\n compact?: boolean;\n type?: string;\n tag?: string;\n minConfidence?: number;\n explain?: boolean;\n}): Promise<RecallResult> {\n return recall(getDb(), {\n query,\n limit: opts?.limit ?? 10,\n compact: opts?.compact ?? true,\n type: opts?.type,\n tag: opts?.tag,\n minConfidence: opts?.minConfidence,\n explain: opts?.explain,\n scope: currentProject,\n });\n}\n\nexport async function memoryContext(topic: string, maxTokens?: number): Promise<ContextResult> {\n return buildContext(getDb(), topic, { maxTokens, scope: currentProject });\n}\n\nexport async function memoryStore(opts: StoreOptions): Promise<StoreResult> {\n const result = await storeMemory(getDb(), opts);\n // Fire-and-forget mirror write. Null-safe via optional chaining; the\n // engine's internal onError swallows all I/O failures so mirror errors\n // cannot break a memory save. `private` action from the sanitizer means\n // no memory was written, so skip the mirror call in that case.\n if (result.action !== \"private\" && mirrorEngine) {\n const saved = getDb().getById(result.id);\n if (saved) void mirrorEngine.onSave(saved);\n }\n return result;\n}\n\nexport function memoryLog(sessionId: string, role: string, content: string): string {\n return getDb().appendLog({\n sessionId,\n role: role as \"user\" | \"assistant\" | \"system\",\n content,\n project: currentProject,\n metadata: {},\n });\n}\n\nexport function reminderCheck(): Array<{ id: string; content: string; dueAt: number | null; status: \"overdue\" | \"today\" | \"upcoming\"; scope: string }> {\n const all = getDb().checkReminders();\n return all.filter((r) => r.scope === \"global\" || r.scope === currentProject);\n}\n\nexport async function memoryForget(opts: { id?: string; query?: string; type?: string }): Promise<{ deleted: number; message: string }> {\n const db = getDb();\n if (opts.id) {\n const fullId = db.resolveId(opts.id) ?? opts.id;\n const memory = db.getById(fullId);\n if (!memory) return { deleted: 0, message: `Memory ${opts.id} not found.` };\n db.deleteMemory(fullId);\n const vecIdx = getVectorIndex();\n if (vecIdx) vecIdx.remove(fullId);\n void mirrorEngine?.onDelete(fullId, memory.type);\n return { deleted: 1, message: `Deleted: \"${memory.content}\" (${memory.type})` };\n }\n // Type-based delete: delete all memories of a given type\n if (opts.type) {\n const all = db.getAllForProject(currentProject);\n const matches = all.filter((m) => m.type === opts.type);\n if (matches.length === 0) return { deleted: 0, message: `No memories of type \"${opts.type}\" found.` };\n const vecIdx = getVectorIndex();\n for (const m of matches) {\n db.deleteMemory(m.id);\n if (vecIdx) vecIdx.remove(m.id);\n void mirrorEngine?.onDelete(m.id, m.type);\n }\n return { deleted: matches.length, message: `Deleted ${matches.length} \"${opts.type}\" memories.` };\n }\n if (opts.query) {\n const queryEmbedding = await generateEmbedding(opts.query);\n const matches = recallMemories(db, { query: opts.query, queryEmbedding, limit: 50, minConfidence: 0, scope: currentProject });\n if (matches.length === 0) return { deleted: 0, message: `No memories found matching \"${opts.query}\".` };\n const vecIdx = getVectorIndex();\n for (const m of matches) {\n db.deleteMemory(m.id);\n if (vecIdx) vecIdx.remove(m.id);\n void mirrorEngine?.onDelete(m.id, m.type);\n }\n return { deleted: matches.length, message: `Deleted ${matches.length} memories matching \"${opts.query}\".` };\n }\n return { deleted: 0, message: \"Provide an id, type, or query to forget.\" };\n}\n\nlet _localMemoryConfig: { maxStaleDays?: number; minConfidence?: number; minAccessCount?: number; maxRecallTokens?: number } = {};\n\nexport function setMemoryConfig(config: typeof _localMemoryConfig): void {\n _localMemoryConfig = config;\n}\n\nexport function getMaxRecallTokens(): number {\n return _localMemoryConfig.maxRecallTokens ?? 1500;\n}\n\nexport function memoryConsolidate(dryRun = false): ConsolidationReport {\n return consolidateMemories(getDb(), cosineSimilarity, {\n dryRun,\n maxStaleDays: _localMemoryConfig.maxStaleDays ?? 90,\n minConfidence: _localMemoryConfig.minConfidence ?? 0.3,\n minAccessCount: _localMemoryConfig.minAccessCount ?? 0,\n });\n}\n\nexport function isMemoryInitialized(): boolean {\n return db !== null;\n}\n\nexport function memoryStats(): MemoryStats {\n return getDb().getStats();\n}\n\nexport function memoryExport(): Memory[] {\n return getDb().getAllForProject(currentProject);\n}\n\nexport function memorySince(hours: number): Memory[] {\n const since = Date.now() - hours * 60 * 60 * 1000;\n const all = getDb().getMemoriesSince(since);\n return all.filter((m) => m.scope === \"global\" || m.scope === currentProject);\n}\n\nexport function memorySearch(query: string, limit?: number): Memory[] {\n return getDb().fullTextSearch(query, limit, currentProject);\n}\n\nexport function reminderSet(content: string, dueAt?: number): string {\n return getDb().insertReminder(content, dueAt ?? null, currentProject);\n}\n\nexport function reminderList(includeCompleted?: boolean): Array<{ id: string; content: string; dueAt: number | null; completed: boolean; createdAt: number; scope: string }> {\n return getDb().listReminders(includeCompleted, currentProject);\n}\n\nexport function reminderComplete(id: string): boolean {\n const fullId = getDb().resolveReminderId(id) ?? id;\n return getDb().completeReminder(fullId);\n}\n\nexport { type RecallResult, type ContextResult, type StoreResult, type StoreOptions, type ConsolidationReport, type MemoryStats, type Memory };\n\n// ─── Admin: Doctor ───────────────────────────────────────────────────────────\n\n/**\n * Run read-only health diagnostics on the amem database.\n * Returns a structured report with status, stats, and a list of issues.\n */\nexport async function memoryDoctor(): Promise<DiagnosticReport> {\n return runDiagnostics(getDb());\n}\n\n// ─── Admin: Repair ───────────────────────────────────────────────────────────\n\nexport interface MemoryRepairResult {\n dryRun: boolean;\n status: \"healthy\" | \"warning\" | \"critical\";\n issues: string[];\n actions: string[];\n}\n\n/**\n * Diagnose and optionally repair the memory database.\n * By default runs in dry-run mode — set dryRun:false to apply fixes.\n */\nexport async function memoryRepair(\n opts: { dryRun?: boolean } = {}\n): Promise<MemoryRepairResult> {\n const dryRun = opts.dryRun ?? true;\n if (dryRun) {\n // Dry-run: run diagnostics and surface what would be repaired\n const diag = runDiagnostics(getDb());\n return {\n dryRun: true,\n status: diag.status,\n issues: diag.issues.map((issue) => issue.message),\n actions: diag.issues.map((issue) => `Would fix: ${issue.suggestion}`),\n };\n }\n // Actual repair: call repairDatabase with the DB path\n const dbPath = process.env.AMEM_DB ?? path.join(os.homedir(), \".amem\", \"memory.db\");\n const result = repairDatabase(dbPath);\n return {\n dryRun: false,\n status: result.status === \"repaired\" ? \"warning\" : result.status === \"failed\" ? \"critical\" : \"healthy\",\n issues: [],\n actions: result.memoriesRecovered > 0 ? [`Recovered ${result.memoriesRecovered} memories`] : [],\n };\n}\n\n// ─── Admin: Config ───────────────────────────────────────────────────────────\n\n/**\n * Read or update the amem configuration.\n * With no args, returns the current config.\n * With updates, deep-merges and saves the new config, then returns authoritative post-save state.\n */\nexport async function memoryConfig(\n updates?: DeepPartial<AmemConfig>\n): Promise<AmemConfig> {\n const current = loadConfig();\n if (updates && Object.keys(updates).length > 0) {\n saveConfig(updates as Partial<AmemConfig>);\n return loadConfig(); // read back authoritative merged state\n }\n return current;\n}\n\n// ─── Advanced Recall ─────────────────────────────────────────────────────────\n\n/**\n * Multi-strategy recall: combines semantic, FTS5, knowledge graph, and\n * temporal scoring into a unified ranked result.\n */\nexport async function memoryMultiRecall(\n query: string,\n opts: { limit?: number; scope?: string } = {}\n): Promise<{ memories: Awaited<ReturnType<typeof multiStrategyRecall>>; total: number }> {\n const queryEmbedding = await generateEmbedding(query);\n const memories = await multiStrategyRecall(getDb(), {\n query,\n queryEmbedding,\n limit: opts.limit ?? 10,\n scope: opts.scope ?? currentProject ?? undefined,\n });\n return { memories, total: memories.length };\n}\n\n// ─── Reflection ──────────────────────────────────────────────────────────────\n\n/**\n * Run the self-evolving memory reflection engine.\n * Returns clusters, contradictions, and synthesis candidates.\n */\nexport async function memoryReflect(\n config?: Partial<ReflectionConfig>\n): Promise<ReflectionReport> {\n return reflect(getDb(), config);\n}\n\n/**\n * Check whether a reflection run is due based on last-run metadata.\n */\nexport function checkReflectionDue(): { due: boolean; reason: string } {\n return isReflectionDue(getDb());\n}\n\n// ─── Tier ────────────────────────────────────────────────────────────────────\n\n/**\n * Move a memory between tiers: \"core\" | \"working\" | \"archival\".\n */\nexport function memoryTier(\n id: string,\n tier: string,\n): { id: string; tier: string; ok: true } | { ok: false; error: string } {\n try {\n const db = getDb();\n const fullId = db.resolveId(id) ?? id;\n db.updateTier(fullId, tier);\n return { id: fullId, tier, ok: true };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\n// ─── Detail ──────────────────────────────────────────────────────────────────\n\n/**\n * Get the full Memory object for a given id. Returns null if not found.\n */\nexport function memoryDetail(id: string): Memory | null {\n const db = getDb();\n const fullId = db.resolveId(id) ?? id;\n return db.getById(fullId);\n}\n\n// ─── Relate ──────────────────────────────────────────────────────────────────\n\n/**\n * Add a knowledge-graph relation between two memories.\n */\nexport function memoryRelate(\n fromId: string,\n toId: string,\n type: string,\n strength?: number,\n): { ok: true; relationId: string } | { ok: false; error: string } {\n try {\n const relationId = getDb().addRelation(fromId, toId, type, strength);\n return { ok: true, relationId };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\n// ─── Expire ──────────────────────────────────────────────────────────────────\n\n/**\n * Mark a memory as expired (sets valid_until to now).\n * The optional `reason` string is for caller-side logging only — the db\n * itself stores the timestamp rather than a reason field.\n */\nexport function memoryExpire(\n id: string,\n reason?: string,\n): { ok: true; id: string; reason?: string } | { ok: false; error: string } {\n try {\n const db = getDb();\n const fullId = db.resolveId(id) ?? id;\n db.expireMemory(fullId);\n return { ok: true, id: fullId, ...(reason !== undefined ? { reason } : {}) };\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\n// ─── Versions ────────────────────────────────────────────────────────────────\n\n/**\n * Return the full version history for a memory.\n */\nexport function memoryVersions(id: string): MemoryVersion[] {\n const db = getDb();\n const fullId = db.resolveId(id) ?? id;\n return db.getVersionHistory(fullId);\n}\n\n// ─── Sync ────────────────────────────────────────────────────────────────────\n\nexport type MemorySyncAction = \"import-claude\" | \"export-team\" | \"import-team\" | \"sync-copilot\";\n\nexport interface MemorySyncOptions {\n /** For import-claude: filter to a specific project path */\n projectFilter?: string;\n /** For import-claude / export-team: skip actual writes */\n dryRun?: boolean;\n /** For export-team: output directory */\n outputDir?: string;\n /** For export-team: userId identifier in the export manifest */\n userId?: string;\n /** For import-team: path to the JSON export file */\n filePath?: string;\n /** For sync-copilot: options forwarded to syncToCopilot */\n copilotOptions?: CopilotSyncOptions;\n /** For import-team: options forwarded to importFromTeam */\n importOptions?: TeamImportOptions;\n}\n\n/**\n * Sync memories with Claude Code auto-memory or team members.\n *\n * Actions:\n * - \"import-claude\" — read Claude Code memory files and import into amem\n * - \"export-team\" — write a shareable JSON export for teammates\n * - \"import-team\" — merge a teammate's JSON export into amem\n * - \"sync-copilot\" — update the Copilot instructions file from amem memories\n */\nexport async function memorySync(\n action: MemorySyncAction,\n opts: MemorySyncOptions = {},\n): Promise<SyncResult | TeamImportResult | { file: string; count: number } | CopilotSyncResult | { ok: false; error: string }> {\n const db = getDb();\n try {\n switch (action) {\n case \"import-claude\":\n return await syncFromClaude(db, opts.projectFilter, opts.dryRun ?? false);\n\n case \"export-team\": {\n const exportOptions: TeamExportOptions = {\n userId: opts.userId ?? currentProject,\n };\n return await exportForTeam(db, opts.outputDir ?? process.cwd(), exportOptions);\n }\n\n case \"import-team\":\n if (!opts.filePath) {\n return { ok: false, error: \"filePath is required for import-team\" };\n }\n return await importFromTeam(db, opts.filePath, opts.importOptions);\n\n case \"sync-copilot\":\n return syncToCopilot(db, opts.copilotOptions);\n\n default:\n return { ok: false, error: `Unknown sync action: ${action as string}` };\n }\n } catch (err) {\n return { ok: false, error: err instanceof Error ? err.message : String(err) };\n }\n}\n\nexport type { MemoryVersion, MemoryRelation, SyncResult, TeamImportResult, CopilotSyncResult };\n\n// ─── Mirror (Task 2.3) ───────────────────────────────────────────────────────\n\n/**\n * Expose the process-wide MirrorEngine so slash commands can call\n * `fullMirror()` / `exportSnapshot()` / `status()` directly. Returns\n * `null` when the mirror is disabled in config — callers should\n * surface that to the user instead of crashing.\n */\nexport function getMirrorEngine(): MirrorEngine | null {\n return mirrorEngine;\n}\n\n/**\n * Mirror-specific fields we recognise in a mirror-format markdown file's\n * YAML frontmatter. Parsed in addition to the legacy Claude fields so the\n * round trip (MirrorEngine.serialize → syncFromMirrorDir) is lossless for\n * memory type, confidence, tags, and id.\n */\ninterface MirrorFrontmatter {\n amemId?: string;\n amemType?: string;\n amemConfidence?: number;\n amemTags?: string[];\n amemCreated?: number;\n amemTier?: string;\n}\n\n/**\n * Extract `amem_*` fields from raw frontmatter text. amem-core's\n * `parseFrontmatter` only surfaces Claude-vocab fields (name/description/\n * type/body) — we need a second pass for the lossless fields that\n * MirrorEngine.serializeMemoryFile writes. Tolerant of missing fields;\n * absent values leave the corresponding property undefined.\n */\nfunction extractAmemFields(raw: string): MirrorFrontmatter {\n const out: MirrorFrontmatter = {};\n const block = raw.match(/^---\\s*\\n([\\s\\S]*?)\\n---/);\n if (!block) return out;\n for (const line of block[1].split(\"\\n\")) {\n const kv = line.match(/^(amem_\\w+)\\s*:\\s*(.+)$/);\n if (!kv) continue;\n const key = kv[1];\n const val = kv[2].trim();\n switch (key) {\n case \"amem_id\": out.amemId = val; break;\n case \"amem_type\": out.amemType = val; break;\n case \"amem_tier\": out.amemTier = val; break;\n case \"amem_confidence\": {\n const n = Number(val);\n if (Number.isFinite(n)) out.amemConfidence = n;\n break;\n }\n case \"amem_tags\":\n out.amemTags = val\n .split(\",\")\n .map((t) => t.trim())\n .filter((t) => t.length > 0);\n break;\n case \"amem_created\": {\n const t = Date.parse(val);\n if (!Number.isNaN(t)) out.amemCreated = t;\n break;\n }\n }\n }\n return out;\n}\n\n// Claude-vocab → amem type map, mirrored from amem-core/sync.ts. Used as a\n// fallback when a `.md` file lacks `amem_type` (e.g. a hand-authored note\n// or a file imported from Claude's auto-memory tree). Kept inline here\n// instead of importing because amem-core does not export it.\nconst CLAUDE_TO_AMEM_TYPE: Record<string, \"correction\" | \"decision\" | \"preference\" | \"topology\"> = {\n feedback: \"correction\",\n project: \"decision\",\n user: \"preference\",\n reference: \"topology\",\n};\n\n/**\n * Auto-sync the mirror dir into the DB on agent startup (Task 2.4).\n *\n * Closes the multi-device loop: edits made on machine A's mirror dir\n * (via Dropbox/iCloud/git) land in machine B's DB on next launch.\n *\n * Contract:\n * - Fast no-op when `config.mirror.enabled=false` OR `autoSyncOnStartup=false`:\n * no fs scan, no engine lookup; returns `null`.\n * - Errors are swallowed and surfaced as `null` — a failed sync MUST NOT\n * block startup. Callers can log/ignore.\n * - Returns the SyncResult on success so the caller (index.ts) can emit\n * a subtle log line. Keeping logging in the caller avoids pulling\n * picocolors into memory.ts and keeps the function pure for tests.\n *\n * Must be awaited by the caller so the REPL doesn't accept user input\n * before synced memories are in the DB (otherwise /recall could miss\n * just-synced entries).\n */\nexport async function startupAutoSync(): Promise<SyncResult | null> {\n // Config first — a disabled auto-sync is a genuine no-op, not a\n // cheap-scan-then-bail.\n const cfg = loadAgentConfig();\n const autoSync = cfg?.mirror?.autoSyncOnStartup ?? true;\n const enabled = cfg?.mirror?.enabled ?? true;\n if (!enabled || !autoSync) return null;\n\n const engine = mirrorEngine;\n if (!engine) return null; // construction failed silently earlier\n\n const dir = engine.status().dir;\n try {\n return await syncFromMirrorDir(dir);\n } catch {\n // Swallow — mirror ops are best-effort. Caller may log.\n return null;\n }\n}\n\n/**\n * Import memories from an arbitrary mirror-format directory into the DB.\n *\n * Unlike amem-core's `syncFromClaude` — which scans the Claude auto-memory\n * tree under `~/.claude/projects/<escaped>/memory/` — this helper takes a\n * single directory and walks all `.md` files beneath it (including one\n * level of type-subdirectories, which is MirrorEngine's on-disk layout).\n *\n * The parser is lossless when the file carries `amem_*` frontmatter (the\n * mirror round-trip case). When those fields are missing we fall back to\n * the Claude-vocab type map so files authored elsewhere still import.\n *\n * Dedup: skipped by content hash and by name-FTS match, matching\n * `syncFromClaude`'s behaviour. Scope defaults to `currentProject` for\n * memories that look project-local, `global` for user/preference ones —\n * the same policy syncFromClaude applies.\n */\nexport async function syncFromMirrorDir(dir: string): Promise<SyncResult> {\n const db = getDb();\n const result: SyncResult = {\n imported: 0,\n skipped: 0,\n updated: 0,\n details: [],\n projectsScanned: 1,\n };\n if (!fs.existsSync(dir)) return result;\n\n // Collect .md files one level deep (type-subdirs) plus any at the root.\n const targets: string[] = [];\n const walk = (d: string): void => {\n for (const name of fs.readdirSync(d)) {\n if (name === \"INDEX.md\") continue;\n const full = path.join(d, name);\n const stat = fs.statSync(full);\n if (stat.isDirectory()) walk(full);\n else if (name.endsWith(\".md\")) targets.push(full);\n }\n };\n try { walk(dir); } catch { return result; }\n\n for (const filePath of targets) {\n const raw = fs.readFileSync(filePath, \"utf-8\");\n const parsed = parseFrontmatter(raw, filePath);\n if (!parsed) {\n result.skipped++;\n result.details.push({ action: \"skipped\", name: filePath, type: \"unknown\", reason: \"invalid frontmatter\" });\n continue;\n }\n const amem = extractAmemFields(raw);\n\n // Prefer amem_* fields when present; fall back to the Claude-vocab\n // type map for hand-authored / Claude-sourced files.\n const resolvedType = (amem.amemType as \"correction\" | \"decision\" | \"pattern\" | \"preference\" | \"topology\" | \"fact\" | undefined)\n ?? CLAUDE_TO_AMEM_TYPE[parsed.type];\n if (!resolvedType) {\n result.skipped++;\n result.details.push({ action: \"skipped\", name: parsed.name, type: parsed.type, reason: `Unknown type: ${parsed.type}` });\n continue;\n }\n\n const content = parsed.body;\n if (!content.trim()) {\n result.skipped++;\n result.details.push({ action: \"skipped\", name: parsed.name, type: resolvedType, reason: \"empty body\" });\n continue;\n }\n\n // Dedup by content hash (source of truth) — cheap and deterministic.\n const existing = db.findByContentHash(content);\n if (existing) {\n result.skipped++;\n result.details.push({ action: \"skipped\", name: parsed.name, type: resolvedType, reason: \"duplicate content\" });\n continue;\n }\n\n const embedding = await generateEmbedding(content);\n const confidence = amem.amemConfidence ?? 0.8;\n const isGlobal = resolvedType === \"preference\" || resolvedType === \"correction\";\n const scope = isGlobal ? \"global\" : currentProject;\n const tags = amem.amemTags ?? [\"mirror-sync\"];\n db.insertMemory({\n content,\n type: resolvedType,\n tags,\n confidence,\n source: \"mirror-sync\",\n embedding,\n scope,\n ...(amem.amemTier ? { tier: amem.amemTier } : {}),\n // Preserve original creation time when the mirror file carries it,\n // so round-tripped memories keep their chronology for recency decay.\n ...(amem.amemCreated ? { validFrom: amem.amemCreated } : {}),\n });\n result.imported++;\n result.details.push({ action: \"imported\", name: parsed.name, type: resolvedType });\n }\n\n return result;\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { readObservationEvents, type ObservationSession } from \"./observation.js\";\nimport type { LLMClient, Message } from \"./llm/types.js\";\nimport { log } from \"./logger.js\";\nimport type { SkillCandidate } from \"./crystallization.js\";\n\n// ── Types ──\n\nexport interface PostmortemReport {\n sessionId: string;\n date: string;\n duration: number;\n turnCount: number;\n summary: string;\n goals: string[];\n completed: string[];\n blockers: string[];\n decisions: string[];\n toolUsage: { name: string; count: number; errorRate: number }[];\n fileChanges: string[];\n topicProgression: string[];\n sentimentArc: string;\n patterns: string[];\n recommendations: string[];\n crystallizationCandidates?: SkillCandidate[];\n}\n\n// ── Default directories ──\n\nexport function defaultPostmortemsDir(): string {\n return path.join(os.homedir(), \".acore\", \"postmortems\");\n}\n\nfunction defaultObservationsDir(): string {\n return path.join(os.homedir(), \".acore\", \"observations\");\n}\n\n// ── Smart trigger ──\n\nexport function shouldAutoPostmortem(\n session: ObservationSession,\n messages: Message[],\n): boolean {\n if (messages.length < 6) return false;\n\n const durationMs = Date.now() - session.startedAt;\n return (\n session.stats.toolErrors >= 3 ||\n session.stats.blockers >= 2 ||\n durationMs > 60 * 60_000 ||\n hasAbandonedPlanSteps(messages) ||\n hasSustainedFrustration(session, 5)\n );\n}\n\nfunction hasAbandonedPlanSteps(messages: Message[]): boolean {\n // Check if any message mentions incomplete plan steps (e.g., /plan output with unchecked items)\n const text = messages\n .map((m) => (typeof m.content === \"string\" ? m.content : \"\"))\n .join(\"\\n\");\n const unchecked = (text.match(/- \\[ \\]/g) ?? []).length;\n const checked = (text.match(/- \\[x\\]/g) ?? []).length;\n // If there are plan steps and some are unchecked, the plan was abandoned\n return checked > 0 && unchecked > 0 && unchecked >= checked;\n}\n\nfunction hasSustainedFrustration(session: ObservationSession, threshold: number): boolean {\n // Use stats counter — events may have been flushed to disk\n return session.stats.blockers >= threshold;\n}\n\n// ── Helper: extract text from message content ──\n\nfunction messageContentToText(content: Message[\"content\"]): string {\n if (typeof content === \"string\") return content;\n return content\n .filter((b) => b.type === \"text\")\n .map((b) => (\"text\" in b ? b.text : \"\"))\n .join(\"\");\n}\n\n// ── Report generation ──\n\nconst POSTMORTEM_PROMPT = `Analyze this session and generate a structured post-mortem report.\nReturn ONLY valid JSON matching this schema (no markdown, no explanation):\n\n{\n \"summary\": \"2-3 sentence overview\",\n \"goals\": [\"what the user tried to accomplish\"],\n \"completed\": [\"what actually got done\"],\n \"blockers\": [\"what caused friction\"],\n \"decisions\": [\"key choices made with rationale\"],\n \"sentimentArc\": \"how mood evolved during session\",\n \"patterns\": [\"recurring behaviors worth remembering for future sessions\"],\n \"recommendations\": [\"actionable suggestions for next session\"],\n \"crystallizationCandidates\": [\n {\n \"name\": \"lowercase-kebab-name\",\n \"description\": \"1-sentence description of when this would be useful\",\n \"triggers\": [\"3-8\", \"trigger\", \"keywords\"],\n \"approach\": \"1-paragraph context: when and why to use this procedure\",\n \"steps\": [\"ordered step 1\", \"ordered step 2\"],\n \"gotchas\": [\"common mistake 1\"],\n \"confidence\": 0.0\n }\n ]\n}\n\nCRYSTALLIZATION RULES:\n- Only suggest 0-2 candidates per session — if nothing qualifies, return an empty array\n- Only suggest REUSABLE procedures (not one-off tasks specific to today's work)\n- The user must have demonstrated the procedure in this session\n- Confidence < 0.6 → don't suggest at all\n- Skip vague things like \"use library X\" — that's not procedural knowledge\n- Prefer narrow specific procedures over broad generalizations\n- Trigger keywords should be highly specific (avoid generic words like \"code\", \"fix\", \"the\")`;\n\nexport async function generatePostmortemReport(\n sessionId: string,\n messages: Message[],\n session: ObservationSession,\n client: LLMClient,\n obsDir?: string,\n rejectedSkillNames?: string[],\n): Promise<PostmortemReport | null> {\n try {\n const events = await readObservationEvents(sessionId, obsDir ?? defaultObservationsDir());\n\n // Compute tool usage from events\n const toolMap = new Map<string, { calls: number; errors: number }>();\n const fileChanges: string[] = [];\n const topicProgression: string[] = [];\n\n for (const event of events) {\n if (event.type === \"tool_call\") {\n const name = (event.data.tool as string) ?? \"unknown\";\n const entry = toolMap.get(name) ?? { calls: 0, errors: 0 };\n entry.calls++;\n toolMap.set(name, entry);\n } else if (event.type === \"tool_error\") {\n const name = (event.data.tool as string) ?? \"unknown\";\n const entry = toolMap.get(name) ?? { calls: 0, errors: 0 };\n entry.errors++;\n toolMap.set(name, entry);\n } else if (event.type === \"file_change\") {\n const p = (event.data.path as string) ?? \"unknown\";\n if (!fileChanges.includes(p)) fileChanges.push(p);\n } else if (event.type === \"topic_shift\") {\n const topics = (event.data.newTopics as string[]) ?? [];\n topicProgression.push(...topics);\n }\n }\n\n const toolUsage = [...toolMap.entries()].map(([name, { calls, errors }]) => ({\n name,\n count: calls,\n errorRate: calls > 0 ? Math.round((errors / calls) * 100) / 100 : 0,\n }));\n\n // Build LLM prompt with capped context\n const recentMessages = messages.slice(-20).map((m) => {\n const text = messageContentToText(m.content);\n return `${m.role}: ${text.slice(0, 200)}`;\n });\n const obsSnapshot = events.slice(-30).map((e) => `[${e.type}] ${e.summary}`);\n\n const durationMin = Math.round((Date.now() - session.startedAt) / 60_000);\n\n const prompt = `${POSTMORTEM_PROMPT}${\n rejectedSkillNames && rejectedSkillNames.length > 0\n ? `\\n\\nPREVIOUSLY REJECTED SKILLS (do NOT suggest these again):\\n${rejectedSkillNames.map((n) => `- ${n}`).join(\"\\n\")}`\n : \"\"\n }\n\nSession ID: ${sessionId}\nDuration: ${durationMin} minutes\nTurns: ${messages.length}\nTool calls: ${session.stats.toolCalls} (${session.stats.toolErrors} errors)\nBlockers: ${session.stats.blockers}\nMilestones: ${session.stats.milestones}\n\nRecent messages:\n${recentMessages.join(\"\\n\")}\n\nObservations:\n${obsSnapshot.join(\"\\n\")}`;\n\n const response = await client.chat(\n \"You are a session analyst. Output only valid JSON.\",\n [{ role: \"user\", content: prompt }],\n () => {}, // no-op onChunk — postmortem runs silently\n );\n\n const text = messageContentToText(response.message.content);\n const jsonMatch = text.match(/\\{[\\s\\S]*\\}/);\n if (!jsonMatch) {\n log.debug(\"postmortem\", \"LLM returned non-JSON response\");\n return null;\n }\n\n const parsed = JSON.parse(jsonMatch[0]);\n\n return {\n sessionId,\n date: new Date().toISOString().slice(0, 10),\n duration: durationMin,\n turnCount: messages.length,\n summary: parsed.summary ?? \"\",\n goals: parsed.goals ?? [],\n completed: parsed.completed ?? [],\n blockers: parsed.blockers ?? [],\n decisions: parsed.decisions ?? [],\n toolUsage,\n fileChanges,\n topicProgression: [...new Set(topicProgression)],\n sentimentArc: parsed.sentimentArc ?? \"\",\n patterns: parsed.patterns ?? [],\n recommendations: parsed.recommendations ?? [],\n crystallizationCandidates: Array.isArray(parsed.crystallizationCandidates)\n ? parsed.crystallizationCandidates\n : undefined,\n };\n } catch (err) {\n log.debug(\"postmortem\", \"Failed to generate post-mortem\", err);\n return null;\n }\n}\n\n// ── Markdown formatting ──\n\nexport function formatPostmortemMarkdown(report: PostmortemReport): string {\n const lines: string[] = [\n `# Post-Mortem: ${report.date}`,\n \"\",\n `**Session:** ${report.sessionId} | **Duration:** ${report.duration} min | **Turns:** ${report.turnCount}`,\n \"\",\n \"## Summary\",\n report.summary,\n \"\",\n ];\n\n if (report.goals.length > 0) {\n lines.push(\"## Goals\");\n report.goals.forEach((g) => lines.push(`- ${g}`));\n lines.push(\"\");\n }\n\n if (report.completed.length > 0) {\n lines.push(\"## Completed\");\n report.completed.forEach((c) => lines.push(`- [x] ${c}`));\n lines.push(\"\");\n }\n\n if (report.blockers.length > 0) {\n lines.push(\"## Blockers\");\n report.blockers.forEach((b) => lines.push(`- ${b}`));\n lines.push(\"\");\n }\n\n if (report.decisions.length > 0) {\n lines.push(\"## Decisions\");\n report.decisions.forEach((d) => lines.push(`- ${d}`));\n lines.push(\"\");\n }\n\n if (report.toolUsage.length > 0) {\n lines.push(\"## Tool Usage\");\n lines.push(\"| Tool | Calls | Error Rate |\");\n lines.push(\"|------|-------|------------|\");\n report.toolUsage.forEach((t) =>\n lines.push(`| ${t.name} | ${t.count} | ${Math.round(t.errorRate * 100)}% |`),\n );\n lines.push(\"\");\n }\n\n if (report.fileChanges.length > 0) {\n lines.push(\"## Files Changed\");\n report.fileChanges.forEach((f) => lines.push(`- \\`${f}\\``));\n lines.push(\"\");\n }\n\n if (report.topicProgression.length > 0) {\n lines.push(`## Topics`);\n lines.push(report.topicProgression.join(\" → \"));\n lines.push(\"\");\n }\n\n if (report.sentimentArc) {\n lines.push(\"## Sentiment Arc\");\n lines.push(report.sentimentArc);\n lines.push(\"\");\n }\n\n if (report.patterns.length > 0) {\n lines.push(\"## Patterns\");\n report.patterns.forEach((p) => lines.push(`- ${p}`));\n lines.push(\"\");\n }\n\n if (report.recommendations.length > 0) {\n lines.push(\"## Recommendations\");\n report.recommendations.forEach((r) => lines.push(`- ${r}`));\n lines.push(\"\");\n }\n\n if (\n report.crystallizationCandidates &&\n report.crystallizationCandidates.length > 0\n ) {\n lines.push(\"## Crystallization Candidates\");\n report.crystallizationCandidates.forEach((c) => {\n lines.push(`- **${c.name}** (confidence ${c.confidence})`);\n lines.push(` ${c.description}`);\n });\n lines.push(\"\");\n }\n\n return lines.join(\"\\n\");\n}\n\n// ── Save & read post-mortems ──\n\nexport async function savePostmortem(\n report: PostmortemReport,\n dir?: string,\n): Promise<string> {\n const pmDir = dir ?? defaultPostmortemsDir();\n await fs.mkdir(pmDir, { recursive: true });\n\n const shortId = report.sessionId.slice(0, 4);\n const fileName = `${report.date}-${shortId}.md`;\n const filePath = path.join(pmDir, fileName);\n\n const markdown = formatPostmortemMarkdown(report);\n await fs.writeFile(filePath, markdown, \"utf-8\");\n\n // Also write a JSON sidecar for lossless re-parsing\n const jsonPath = filePath.replace(/\\.md$/, \".json\");\n try {\n await fs.writeFile(jsonPath, JSON.stringify(report, null, 2), \"utf-8\");\n } catch (err) {\n log.debug(\"postmortem\", \"JSON sidecar write failed\", err);\n }\n\n return filePath;\n}\n\nexport async function listPostmortems(dir?: string): Promise<string[]> {\n const pmDir = dir ?? defaultPostmortemsDir();\n try {\n const files = await fs.readdir(pmDir);\n return files\n .filter((f) => f.endsWith(\".md\"))\n .sort()\n .reverse();\n } catch {\n return [];\n }\n}\n\nexport async function readPostmortem(\n name: string,\n dir?: string,\n): Promise<string | null> {\n const pmDir = dir ?? defaultPostmortemsDir();\n const fileName = name.endsWith(\".md\") ? name : `${name}.md`;\n try {\n return await fs.readFile(path.join(pmDir, fileName), \"utf-8\");\n } catch {\n return null;\n }\n}\n\nexport async function analyzePostmortemRange(\n sinceDays: number,\n client: LLMClient,\n dir?: string,\n): Promise<string | null> {\n const pmDir = dir ?? defaultPostmortemsDir();\n try {\n const files = await listPostmortems(pmDir);\n const cutoffDate = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000)\n .toISOString()\n .slice(0, 10);\n\n const recentFiles = files.filter((f) => f >= cutoffDate);\n if (recentFiles.length === 0) return \"No post-mortems found in the specified range.\";\n\n const contents: string[] = [];\n for (const f of recentFiles.slice(0, 10)) {\n const content = await readPostmortem(f, pmDir);\n if (content) contents.push(content);\n }\n\n const response = await client.chat(\n \"You are a session analyst. Analyze these post-mortems and identify trends.\",\n [\n {\n role: \"user\",\n content: `Analyze these ${contents.length} post-mortem reports from the last ${sinceDays} days. Identify:\n1. Recurring blockers\n2. Productivity patterns\n3. Tool reliability issues\n4. Topic continuity across sessions\n5. Actionable recommendations\n\nReports:\n${contents.join(\"\\n\\n---\\n\\n\")}`,\n },\n ],\n () => {}, // no-op onChunk\n );\n\n const text = messageContentToText(response.message.content);\n return text || null;\n } catch (err) {\n log.debug(\"postmortem\", \"Failed to analyze range\", err);\n return null;\n }\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\n\n// ── Types ──\n\nexport type ObservationEventType =\n | \"tool_call\"\n | \"tool_error\"\n | \"topic_shift\"\n | \"decision\"\n | \"blocker\"\n | \"milestone\"\n | \"file_change\"\n | \"sentiment_shift\"\n | \"error\"\n | \"phase_start\"\n | \"phase_complete\"\n | \"approval_gate\"\n | \"task_delegated\";\n\nexport interface ObservationEvent {\n timestamp: number;\n type: ObservationEventType;\n summary: string;\n data: Record<string, unknown>;\n}\n\nexport interface ObservationSession {\n sessionId: string;\n startedAt: number;\n events: ObservationEvent[];\n paused: boolean;\n stats: {\n toolCalls: number;\n toolErrors: number;\n topicShifts: number;\n blockers: number;\n milestones: number;\n fileChanges: number;\n };\n}\n\n// ── Stat counters by event type ──\n\nconst STAT_MAP: Partial<Record<ObservationEventType, keyof ObservationSession[\"stats\"]>> = {\n tool_call: \"toolCalls\",\n tool_error: \"toolErrors\",\n topic_shift: \"topicShifts\",\n blocker: \"blockers\",\n milestone: \"milestones\",\n file_change: \"fileChanges\",\n};\n\n// ── Default observations directory ──\n\nexport function defaultObservationsDir(): string {\n return path.join(os.homedir(), \".acore\", \"observations\");\n}\n\n// ── Core functions ──\n\nexport function createObservationSession(sessionId: string): ObservationSession {\n return {\n sessionId,\n startedAt: Date.now(),\n events: [],\n paused: false,\n stats: {\n toolCalls: 0,\n toolErrors: 0,\n topicShifts: 0,\n blockers: 0,\n milestones: 0,\n fileChanges: 0,\n },\n };\n}\n\nexport function recordEvent(\n session: ObservationSession,\n event: Omit<ObservationEvent, \"timestamp\">,\n): void {\n if (session.paused) return;\n\n const full: ObservationEvent = { ...event, timestamp: Date.now() };\n session.events.push(full);\n\n const statKey = STAT_MAP[event.type];\n if (statKey) {\n session.stats[statKey]++;\n }\n}\n\nexport function pauseObservation(session: ObservationSession): void {\n session.paused = true;\n}\n\nexport function resumeObservation(session: ObservationSession): void {\n session.paused = false;\n}\n\nexport async function flushEvents(\n session: ObservationSession,\n dir?: string,\n): Promise<void> {\n if (session.events.length === 0) return;\n\n const obsDir = dir ?? defaultObservationsDir();\n await fs.mkdir(obsDir, { recursive: true });\n\n const filePath = path.join(obsDir, `${session.sessionId}.jsonl`);\n const lines = session.events.map((e) => JSON.stringify(e)).join(\"\\n\") + \"\\n\";\n await fs.appendFile(filePath, lines, \"utf-8\");\n\n session.events.length = 0;\n}\n\nexport async function readObservationEvents(\n sessionId: string,\n dir?: string,\n): Promise<ObservationEvent[]> {\n const obsDir = dir ?? defaultObservationsDir();\n const filePath = path.join(obsDir, `${sessionId}.jsonl`);\n\n try {\n const content = await fs.readFile(filePath, \"utf-8\");\n return content\n .trim()\n .split(\"\\n\")\n .filter((line) => line.length > 0)\n .map((line) => JSON.parse(line) as ObservationEvent);\n } catch {\n return [];\n }\n}\n\nexport function getSessionStats(session: ObservationSession): string {\n const elapsed = Math.round((Date.now() - session.startedAt) / 60_000);\n const s = session.stats;\n const parts = [\n `Session: ${elapsed} min`,\n `Tools: ${s.toolCalls} calls (${s.toolErrors} error${s.toolErrors !== 1 ? \"s\" : \"\"})`,\n `Files: ${s.fileChanges} changed`,\n `Blockers: ${s.blockers}`,\n `Milestones: ${s.milestones}`,\n ];\n if (s.topicShifts > 0) parts.push(`Topic shifts: ${s.topicShifts}`);\n if (session.paused) parts.push(\"(paused)\");\n return parts.join(\" | \");\n}\n\nexport async function cleanupOldObservations(\n dir?: string,\n maxAgeDays = 30,\n): Promise<void> {\n const obsDir = dir ?? defaultObservationsDir();\n try {\n const files = await fs.readdir(obsDir);\n const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;\n\n for (const file of files) {\n if (!file.endsWith(\".jsonl\")) continue;\n const filePath = path.join(obsDir, file);\n const stat = await fs.stat(filePath);\n if (stat.mtimeMs < cutoff) {\n await fs.unlink(filePath);\n }\n }\n } catch {\n // Directory may not exist yet — that's fine\n }\n}\n\n// ── Topic shift detection ──\n\nexport function detectTopicShift(\n recentMessages: string[],\n previousMessages: string[],\n): { shifted: boolean; newTopics: string[] } {\n const extractKeywords = (msgs: string[]): Set<string> => {\n const words = msgs\n .join(\" \")\n .toLowerCase()\n .split(/\\W+/)\n .filter((w) => w.length > 3);\n return new Set(words);\n };\n\n const recent = extractKeywords(recentMessages);\n const previous = extractKeywords(previousMessages);\n\n if (previous.size === 0) return { shifted: false, newTopics: [] };\n\n let overlap = 0;\n for (const word of recent) {\n if (previous.has(word)) overlap++;\n }\n\n const overlapRatio = previous.size > 0 ? overlap / previous.size : 1;\n const shifted = overlapRatio < 0.3;\n\n const newTopics = shifted\n ? [...recent].filter((w) => !previous.has(w)).slice(0, 5)\n : [];\n\n return { shifted, newTopics };\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { log } from \"./logger.js\";\n\n// ── Types ──\n\nexport interface SkillCandidate {\n name: string;\n description: string;\n triggers: string[];\n approach: string;\n steps: string[];\n gotchas: string[];\n confidence: number;\n}\n\nexport interface CrystallizationResult {\n written: boolean;\n filePath: string;\n skillName: string;\n reason?: string;\n collidesWith?: string;\n}\n\nexport interface MarkerData {\n source: string;\n date: string;\n confidence: number;\n triggers: string[];\n}\n\nexport interface CrystallizationLogEntry {\n name: string;\n createdAt: string;\n fromPostmortem: string;\n confidence: number;\n triggers: string[];\n}\n\nexport interface RejectionLogEntry {\n name: string;\n rejectedAt: string;\n fromPostmortem: string;\n triggers: string[];\n}\n\nexport interface CollisionResult {\n collides: boolean;\n collidesWith?: string;\n reason?: string;\n}\n\n// ── Constants ──\n\nconst STOPWORDS = new Set([\n \"the\", \"and\", \"is\", \"to\", \"of\", \"a\", \"in\", \"for\", \"on\", \"with\",\n \"this\", \"that\", \"it\", \"as\", \"be\", \"by\", \"or\", \"at\", \"an\", \"from\",\n \"code\", \"fix\", \"do\", \"use\", \"make\", \"get\", \"set\", \"run\", \"we\", \"i\",\n]);\n\nconst MAX_REJECTIONS = 100;\nconst MARKER_RE = /<!--\\s*aman-auto\\s+([^>]+?)\\s*-->/;\n\n// ── sanitizeName ──\n\nexport function sanitizeName(input: string): string {\n const cleaned = input\n .toLowerCase()\n .trim()\n .replace(/[^a-z0-9\\s-]/g, \" \")\n .replace(/\\s+/g, \"-\")\n .replace(/-+/g, \"-\")\n .replace(/^-|-$/g, \"\");\n\n if (cleaned.length === 0) {\n throw new Error(`Cannot sanitize name: \"${input}\" produced empty result`);\n }\n return cleaned;\n}\n\n// ── validateCandidate ──\n\nexport function validateCandidate(raw: unknown): SkillCandidate | null {\n if (!raw || typeof raw !== \"object\") return null;\n const c = raw as Record<string, unknown>;\n\n if (typeof c.name !== \"string\" || c.name.trim() === \"\") return null;\n if (typeof c.description !== \"string\") return null;\n if (typeof c.approach !== \"string\") return null;\n if (!Array.isArray(c.triggers) || c.triggers.length === 0) return null;\n if (c.triggers.length > 10) return null;\n if (!Array.isArray(c.steps)) return null;\n if (typeof c.confidence !== \"number\") return null;\n if (!Number.isFinite(c.confidence)) return null;\n\n if (c.confidence < 0.6) return null;\n\n const triggers = Array.from(\n new Set(\n c.triggers\n .filter((t): t is string => typeof t === \"string\")\n .map((t) => t.toLowerCase().trim())\n .filter((t) => t.length > 0 && !STOPWORDS.has(t))\n )\n );\n\n if (triggers.length === 0) return null;\n\n let name: string;\n try {\n name = sanitizeName(c.name);\n } catch {\n return null;\n }\n\n return {\n name,\n description: c.description,\n triggers,\n approach: c.approach,\n steps: c.steps.filter((s): s is string => typeof s === \"string\"),\n gotchas: Array.isArray(c.gotchas)\n ? c.gotchas.filter((g): g is string => typeof g === \"string\")\n : [],\n confidence: Math.min(1, Math.max(0, c.confidence)),\n };\n}\n\n// ── formatSkillMarkdown ──\n\nfunction toTitleCase(kebab: string): string {\n return kebab\n .split(\"-\")\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(\" \");\n}\n\nexport function formatSkillMarkdown(\n candidate: SkillCandidate,\n postmortemFilename: string,\n): string {\n const date = new Date().toISOString().slice(0, 10);\n const heading = toTitleCase(candidate.name);\n const triggerStr = candidate.triggers.join(\",\");\n\n const lines: string[] = [\n `# ${heading}`,\n `<!-- aman-auto source=postmortem date=${date} confidence=${candidate.confidence} triggers=\"${triggerStr}\" -->`,\n \"\",\n \"## When to use\",\n candidate.approach,\n \"\",\n \"## Steps\",\n ...candidate.steps.map((s, i) => `${i + 1}. ${s}`),\n \"\",\n ];\n\n if (candidate.gotchas.length > 0) {\n lines.push(\"## Gotchas\");\n lines.push(...candidate.gotchas.map((g) => `- ${g}`));\n lines.push(\"\");\n }\n\n lines.push(`<!-- generated from ${postmortemFilename} -->`);\n lines.push(\"\");\n\n return lines.join(\"\\n\");\n}\n\n// ── parseMarkerComment ──\n\nexport function parseMarkerComment(line: string): MarkerData | null {\n const match = line.match(MARKER_RE);\n if (!match) return null;\n\n const attrs: Record<string, string> = {};\n const attrRe = /(\\w+)=(?:\"([^\"]*)\"|(\\S+))/g;\n let m: RegExpExecArray | null;\n while ((m = attrRe.exec(match[1])) !== null) {\n attrs[m[1]] = m[2] ?? m[3] ?? \"\";\n }\n\n if (!attrs.triggers) return null;\n\n const triggers = attrs.triggers\n .split(\",\")\n .map((t) => t.trim())\n .filter((t) => t.length > 0);\n\n if (triggers.length === 0) return null;\n\n return {\n source: attrs.source ?? \"unknown\",\n date: attrs.date ?? \"\",\n confidence: attrs.confidence ? Number(attrs.confidence) : 0,\n triggers,\n };\n}\n\n// ── extractSkillsWithMarkers ──\n\nexport function extractSkillsWithMarkers(\n skillsMdContent: string,\n): Map<string, MarkerData> {\n const result = new Map<string, MarkerData>();\n const lines = skillsMdContent.split(\"\\n\");\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (line.startsWith(\"# \") && i + 1 < lines.length) {\n const headingText = line.slice(2).trim();\n const nextLine = lines[i + 1];\n const marker = parseMarkerComment(nextLine);\n if (marker) {\n try {\n const skillName = sanitizeName(headingText);\n result.set(skillName, marker);\n } catch {\n log.debug(\"crystallization\", `cannot sanitize heading: ${headingText}`);\n }\n }\n }\n }\n\n return result;\n}\n\n// ── findCollision ──\n\nexport function findCollision(\n name: string,\n triggers: string[],\n existing: Map<string, MarkerData>,\n): CollisionResult {\n if (existing.has(name)) {\n return { collides: true, collidesWith: name, reason: \"exact name match\" };\n }\n\n const triggerSet = new Set(triggers);\n for (const [otherName, otherData] of existing) {\n const otherTriggers = new Set(otherData.triggers);\n const intersection = [...triggerSet].filter((t) => otherTriggers.has(t)).length;\n const union = new Set([...triggerSet, ...otherTriggers]).size;\n const overlap = union > 0 ? intersection / union : 0;\n if (overlap >= 0.8) {\n return {\n collides: true,\n collidesWith: otherName,\n reason: `${Math.round(overlap * 100)}% trigger overlap`,\n };\n }\n }\n\n return { collides: false };\n}\n\n// ── writeSkillToFile ──\n\nexport async function writeSkillToFile(\n candidate: SkillCandidate,\n skillsMdPath: string,\n postmortemFilename: string,\n): Promise<CrystallizationResult> {\n try {\n await fs.mkdir(path.dirname(skillsMdPath), { recursive: true });\n\n let existingContent = \"\";\n try {\n existingContent = await fs.readFile(skillsMdPath, \"utf-8\");\n } catch {\n existingContent = \"# Skills\\n\\n\";\n }\n\n if (existingContent.trim() === \"\") {\n existingContent = \"# Skills\\n\\n\";\n }\n\n const existingSkills = extractSkillsWithMarkers(existingContent);\n const collision = findCollision(candidate.name, candidate.triggers, existingSkills);\n if (collision.collides) {\n log.debug(\"crystallization\", `collision detected: ${collision.reason}`);\n return {\n written: false,\n filePath: skillsMdPath,\n skillName: candidate.name,\n reason: `collision with \"${collision.collidesWith}\" (${collision.reason})`,\n collidesWith: collision.collidesWith,\n };\n }\n\n const skillMarkdown = formatSkillMarkdown(candidate, postmortemFilename);\n const separator = existingContent.endsWith(\"\\n\\n\")\n ? \"\"\n : existingContent.endsWith(\"\\n\")\n ? \"\\n\"\n : \"\\n\\n\";\n await fs.writeFile(\n skillsMdPath,\n existingContent + separator + skillMarkdown,\n \"utf-8\",\n );\n\n return {\n written: true,\n filePath: skillsMdPath,\n skillName: candidate.name,\n };\n } catch (err) {\n log.warn(\"crystallization\", \"writeSkillToFile failed\", err);\n return {\n written: false,\n filePath: skillsMdPath,\n skillName: candidate.name,\n reason: err instanceof Error ? err.message : String(err),\n };\n }\n}\n\n// ── mergeSkillInFile ──\n\n/**\n * Replace an existing skill block in skills.md with a new candidate.\n * Finds the heading for `existingName`, removes everything up to the next heading or EOF,\n * and writes the new candidate in its place.\n */\nexport async function mergeSkillInFile(\n candidate: SkillCandidate,\n existingName: string,\n skillsMdPath: string,\n postmortemFilename: string,\n): Promise<CrystallizationResult> {\n try {\n const content = await fs.readFile(skillsMdPath, \"utf-8\");\n const lines = content.split(\"\\n\");\n\n // Find the heading line for the existing skill\n const heading = toTitleCase(existingName);\n let startIdx = -1;\n for (let i = 0; i < lines.length; i++) {\n if (lines[i].startsWith(\"# \") && lines[i].slice(2).trim() === heading) {\n startIdx = i;\n break;\n }\n }\n\n if (startIdx === -1) {\n return writeSkillToFile(candidate, skillsMdPath, postmortemFilename);\n }\n\n // Find the end of this skill block (next heading or EOF)\n let endIdx = lines.length;\n for (let i = startIdx + 1; i < lines.length; i++) {\n if (lines[i].startsWith(\"# \") && !lines[i].startsWith(\"## \")) {\n endIdx = i;\n break;\n }\n }\n\n // Determine version number — count existing archived versions\n const versionPattern = new RegExp(`^# ${heading.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\\\.v\\\\d+`);\n let maxVersion = 0;\n for (const line of lines) {\n if (versionPattern.test(line)) {\n const vMatch = line.match(/\\.v(\\d+)/);\n if (vMatch) maxVersion = Math.max(maxVersion, parseInt(vMatch[1], 10));\n }\n }\n const archiveVersion = maxVersion + 1;\n\n // Archive the old version by renaming its heading\n const oldBlock = lines.slice(startIdx, endIdx);\n oldBlock[0] = `# ${heading}.v${archiveVersion}`;\n // Add archive marker\n const archiveMarker = `<!-- aman-archived version=${archiveVersion} archived-at=${new Date().toISOString().slice(0, 10)} -->`;\n if (oldBlock.length > 1 && oldBlock[1].includes(\"aman-auto\")) {\n oldBlock.splice(2, 0, archiveMarker);\n } else {\n oldBlock.splice(1, 0, archiveMarker);\n }\n\n // Write: archived old block + new candidate\n const newSkillMarkdown = formatSkillMarkdown(candidate, postmortemFilename);\n const before = lines.slice(0, startIdx);\n const after = lines.slice(endIdx);\n const merged = [...before, ...oldBlock, \"\", newSkillMarkdown, ...after].join(\"\\n\");\n\n await fs.writeFile(skillsMdPath, merged, \"utf-8\");\n\n return {\n written: true,\n filePath: skillsMdPath,\n skillName: candidate.name,\n reason: `merged with \"${existingName}\" (archived as .v${archiveVersion})`,\n };\n } catch (err) {\n log.warn(\"crystallization\", \"mergeSkillInFile failed\", err);\n return {\n written: false,\n filePath: skillsMdPath,\n skillName: candidate.name,\n reason: err instanceof Error ? err.message : String(err),\n };\n }\n}\n\n// ── Logs ──\n\nexport async function appendCrystallizationLog(\n entry: CrystallizationLogEntry,\n logPath: string,\n): Promise<void> {\n try {\n await fs.mkdir(path.dirname(logPath), { recursive: true });\n let existing: CrystallizationLogEntry[] = [];\n try {\n const content = await fs.readFile(logPath, \"utf-8\");\n existing = JSON.parse(content);\n if (!Array.isArray(existing)) existing = [];\n } catch {\n existing = [];\n }\n existing.push(entry);\n await fs.writeFile(logPath, JSON.stringify(existing, null, 2), \"utf-8\");\n } catch (err) {\n log.debug(\"crystallization\", \"appendCrystallizationLog failed\", err);\n }\n}\n\nexport async function appendRejection(\n candidate: SkillCandidate,\n postmortemFilename: string,\n rejectionsPath: string,\n): Promise<void> {\n try {\n await fs.mkdir(path.dirname(rejectionsPath), { recursive: true });\n let existing: RejectionLogEntry[] = [];\n try {\n const content = await fs.readFile(rejectionsPath, \"utf-8\");\n existing = JSON.parse(content);\n if (!Array.isArray(existing)) existing = [];\n } catch {\n existing = [];\n }\n\n existing.push({\n name: candidate.name,\n rejectedAt: new Date().toISOString(),\n fromPostmortem: postmortemFilename,\n triggers: candidate.triggers,\n });\n\n while (existing.length > MAX_REJECTIONS) {\n existing.shift();\n }\n\n await fs.writeFile(rejectionsPath, JSON.stringify(existing, null, 2), \"utf-8\");\n } catch (err) {\n log.debug(\"crystallization\", \"appendRejection failed\", err);\n }\n}\n\n/**\n * Load rejected skill names from the rejections log.\n * Returns unique names. Never throws.\n */\nexport async function loadRejectedNames(rejectionsPath: string): Promise<string[]> {\n try {\n const content = await fs.readFile(rejectionsPath, \"utf-8\");\n const entries: RejectionLogEntry[] = JSON.parse(content);\n if (!Array.isArray(entries)) return [];\n return [...new Set(entries.map((e) => e.name))];\n } catch {\n return [];\n }\n}\n\n// ── Suggestion tracking (cross-session reinforcement) ──\n\nexport interface SuggestionCounts {\n [name: string]: number;\n}\n\n/**\n * Load suggestion counts. Never throws.\n */\nexport async function loadSuggestionCounts(suggestionsPath: string): Promise<SuggestionCounts> {\n try {\n const content = await fs.readFile(suggestionsPath, \"utf-8\");\n const parsed = JSON.parse(content);\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) return {};\n return parsed as SuggestionCounts;\n } catch {\n return {};\n }\n}\n\n/**\n * Increment suggestion count for a candidate name. Returns the new count.\n */\nexport async function incrementSuggestionCount(\n name: string,\n suggestionsPath: string,\n): Promise<number> {\n try {\n await fs.mkdir(path.dirname(suggestionsPath), { recursive: true });\n const counts = await loadSuggestionCounts(suggestionsPath);\n counts[name] = (counts[name] || 0) + 1;\n await fs.writeFile(suggestionsPath, JSON.stringify(counts, null, 2), \"utf-8\");\n return counts[name];\n } catch (err) {\n log.debug(\"crystallization\", \"incrementSuggestionCount failed\", err);\n return 0;\n }\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { log } from \"./logger.js\";\n\n// ── Types ──\n\nexport interface SessionSnapshot {\n sessionId: string;\n date: string;\n durationMinutes: number;\n turnCount: number;\n\n dominantSentiment: string;\n avgFrustration: number;\n avgExcitement: number;\n avgConfusion: number;\n avgFatigue: number;\n\n toolCalls: number;\n toolErrors: number;\n blockers: number;\n milestones: number;\n topicShifts: number;\n\n peakEnergy: string;\n primaryMode: string;\n timePeriod: string;\n\n rating?: string;\n hadPostmortem: boolean;\n wellbeingNudges: string[];\n}\n\nexport interface UserProfile {\n trustScore: number;\n trustTrajectory: \"ascending\" | \"stable\" | \"declining\";\n totalSessions: number;\n\n preferredTimePeriod: string;\n energyDistribution: Record<string, number>;\n avgSessionMinutes: number;\n\n baselineFrustration: number;\n baselineExcitement: number;\n sentimentTrend: \"improving\" | \"stable\" | \"worsening\";\n\n frustrationCorrelations: {\n toolErrors: number;\n longSessions: number;\n lateNight: number;\n };\n\n avgTurnsPerSession: number;\n engagementTrend: \"increasing\" | \"stable\" | \"decreasing\";\n\n nudgeStats: Record<string, { fired: number; sessionRatingAfter: number }>;\n}\n\nexport interface UserModel {\n version: 1;\n sessions: SessionSnapshot[];\n profile: UserProfile;\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface PersonalityOverrides {\n energyOverride?: string;\n compactGreeting: boolean;\n frustrationNudgeThreshold: number;\n defaultToPersonalMode: boolean;\n}\n\n// ── Constants ──\n\nconst MAX_SESSIONS = 30;\nconst TRUST_ALPHA = 0.3;\nconst MIN_SESSIONS_FOR_FEED_FORWARD = 5;\nconst MIN_SESSIONS_FOR_CORRELATIONS = 10;\n\n// ── Default model path ──\n\nexport function defaultModelPath(): string {\n return path.join(os.homedir(), \".acore\", \"user-model.json\");\n}\n\n// ── Factory ──\n\nexport function createEmptyModel(): UserModel {\n const now = new Date().toISOString();\n return {\n version: 1,\n sessions: [],\n profile: emptyProfile(),\n createdAt: now,\n updatedAt: now,\n };\n}\n\nfunction emptyProfile(): UserProfile {\n return {\n trustScore: 0.5,\n trustTrajectory: \"stable\",\n totalSessions: 0,\n preferredTimePeriod: \"afternoon\",\n energyDistribution: {},\n avgSessionMinutes: 0,\n baselineFrustration: 0,\n baselineExcitement: 0,\n sentimentTrend: \"stable\",\n frustrationCorrelations: { toolErrors: 0, longSessions: 0, lateNight: 0 },\n avgTurnsPerSession: 0,\n engagementTrend: \"stable\",\n nudgeStats: {},\n };\n}\n\n// ── I/O ──\n\nexport async function loadUserModel(filePath?: string): Promise<UserModel | null> {\n const fp = filePath ?? defaultModelPath();\n try {\n const raw = await fs.readFile(fp, \"utf-8\");\n const parsed = JSON.parse(raw);\n if (parsed?.version !== 1) return null;\n return parsed as UserModel;\n } catch {\n return null;\n }\n}\n\nexport async function saveUserModel(model: UserModel, filePath?: string): Promise<void> {\n const fp = filePath ?? defaultModelPath();\n const dir = path.dirname(fp);\n await fs.mkdir(dir, { recursive: true });\n\n const tmp = fp + `.tmp-${Date.now()}`;\n await fs.writeFile(tmp, JSON.stringify(model, null, 2), \"utf-8\");\n await fs.rename(tmp, fp);\n}\n\n// ── Aggregation ──\n\nexport function aggregateSession(model: UserModel, snapshot: SessionSnapshot): UserModel {\n const sessions = [...model.sessions, snapshot];\n\n // Enforce rolling window\n while (sessions.length > MAX_SESSIONS) {\n sessions.shift();\n }\n\n const totalSessions = model.profile.totalSessions + 1;\n const profile = computeProfile(sessions, totalSessions);\n\n return {\n ...model,\n sessions,\n profile,\n updatedAt: new Date().toISOString(),\n };\n}\n\n// ── Profile Computation ──\n\nexport function computeProfile(sessions: SessionSnapshot[], totalSessions: number): UserProfile {\n if (sessions.length === 0) return { ...emptyProfile(), totalSessions };\n\n const n = sessions.length;\n\n // ── Trust Score (EMA) ──\n let trustScore = 0.5;\n for (const s of sessions) {\n trustScore = TRUST_ALPHA * ratingSignal(s) + (1 - TRUST_ALPHA) * trustScore;\n }\n\n // Trust trajectory: compare last 5 vs previous 5\n const trustTrajectory = computeTrustTrajectory(sessions);\n\n // ── Sentiment Baselines ──\n const baselineFrustration = avg(sessions.map((s) => s.avgFrustration));\n const baselineExcitement = avg(sessions.map((s) => s.avgExcitement));\n const sentimentTrend = computeSentimentTrend(sessions);\n\n // ── Energy Distribution ──\n const energyDistribution: Record<string, number> = {};\n for (const s of sessions) {\n energyDistribution[s.timePeriod] = (energyDistribution[s.timePeriod] || 0) + 1;\n }\n const preferredTimePeriod = Object.entries(energyDistribution).sort(\n (a, b) => b[1] - a[1],\n )[0]?.[0] ?? \"afternoon\";\n\n // ── Session Duration ──\n const avgSessionMinutes = avg(sessions.map((s) => s.durationMinutes));\n\n // ── Engagement ──\n const avgTurnsPerSession = avg(sessions.map((s) => s.turnCount));\n const engagementTrend = computeLinearTrend(sessions.map((s) => s.turnCount));\n\n // ── Frustration Correlations ──\n const frustrationCorrelations =\n n >= MIN_SESSIONS_FOR_CORRELATIONS\n ? {\n toolErrors: pearsonR(\n sessions.map((s) => s.avgFrustration),\n sessions.map((s) => s.toolErrors),\n ),\n longSessions: pearsonR(\n sessions.map((s) => s.avgFrustration),\n sessions.map((s) => s.durationMinutes),\n ),\n lateNight: pearsonR(\n sessions.map((s) => s.avgFrustration),\n sessions.map((s) => (s.timePeriod === \"late-night\" || s.timePeriod === \"night\" ? 1 : 0)),\n ),\n }\n : { toolErrors: 0, longSessions: 0, lateNight: 0 };\n\n // ── Nudge Stats ──\n const nudgeStats: Record<string, { fired: number; sessionRatingAfter: number }> = {};\n for (const s of sessions) {\n const ratingVal = ratingToNumber(s.rating);\n for (const nudge of s.wellbeingNudges) {\n if (!nudgeStats[nudge]) nudgeStats[nudge] = { fired: 0, sessionRatingAfter: 0 };\n nudgeStats[nudge].fired++;\n nudgeStats[nudge].sessionRatingAfter += ratingVal;\n }\n }\n for (const key of Object.keys(nudgeStats)) {\n if (nudgeStats[key].fired > 0) {\n nudgeStats[key].sessionRatingAfter /= nudgeStats[key].fired;\n }\n }\n\n return {\n trustScore,\n trustTrajectory,\n totalSessions,\n preferredTimePeriod,\n energyDistribution,\n avgSessionMinutes,\n baselineFrustration,\n baselineExcitement,\n sentimentTrend,\n frustrationCorrelations,\n avgTurnsPerSession,\n engagementTrend,\n nudgeStats,\n };\n}\n\n// ── Feed-Forward ──\n\nexport function feedForward(model: UserModel): PersonalityOverrides | null {\n if (model.profile.totalSessions < MIN_SESSIONS_FOR_FEED_FORWARD) return null;\n\n const p = model.profile;\n const overrides: PersonalityOverrides = {\n compactGreeting: false,\n frustrationNudgeThreshold: 0.6,\n defaultToPersonalMode: false,\n };\n\n // Night owl calibration: if 70%+ sessions are late-night/night with low frustration,\n // don't default to \"reflective\" — use \"steady\"\n const nightSessions =\n (p.energyDistribution[\"late-night\"] || 0) + (p.energyDistribution[\"night\"] || 0);\n const totalInWindow = model.sessions.length;\n if (totalInWindow > 0 && nightSessions / totalInWindow >= 0.7 && p.baselineFrustration < 0.3) {\n overrides.energyOverride = \"steady\";\n }\n\n // High trust → compact greeting\n if (p.trustScore > 0.8) {\n overrides.compactGreeting = true;\n }\n\n // Tool error frustration correlation → lower nudge threshold\n if (p.frustrationCorrelations.toolErrors > 0.4) {\n overrides.frustrationNudgeThreshold = 0.4;\n }\n\n // Worsening sentiment → default to Personal mode more readily\n if (p.sentimentTrend === \"worsening\") {\n overrides.defaultToPersonalMode = true;\n }\n\n return overrides;\n}\n\n// ── Burnout Predictor ──\n\nexport interface BurnoutPrediction {\n risk: number; // 0-1\n factors: string[];\n recommendation?: string;\n}\n\n/**\n * Predict burnout risk from session patterns.\n * Looks at recent 7 sessions for:\n * - Rising frustration trend\n * - Declining session ratings\n * - Long sessions without breaks\n * - Late-night clustering\n * - High blocker frequency\n */\nexport function predictBurnout(\n sessions: SessionSnapshot[],\n currentSession?: { minutes: number; frustration: number; timePeriod: string },\n): BurnoutPrediction {\n const recent = sessions.slice(-7);\n if (recent.length < 3) {\n return { risk: 0, factors: [] };\n }\n\n const factors: string[] = [];\n let risk = 0;\n\n // Factor 1: Rising frustration (compare first half vs second half)\n const mid = Math.floor(recent.length / 2);\n const firstHalf = recent.slice(0, mid);\n const secondHalf = recent.slice(mid);\n const avgFrustFirst = avg(firstHalf.map((s) => s.avgFrustration));\n const avgFrustSecond = avg(secondHalf.map((s) => s.avgFrustration));\n if (avgFrustSecond > avgFrustFirst + 0.1 && avgFrustSecond > 0.4) {\n risk += 0.25;\n factors.push(\"rising frustration trend\");\n }\n\n // Factor 2: Declining ratings\n const ratings = recent.filter((s) => s.rating).map((s) => ratingSignal(s));\n if (ratings.length >= 3) {\n const lastThree = ratings.slice(-3);\n const avgLast3 = avg(lastThree);\n if (avgLast3 < 0.5) {\n risk += 0.2;\n factors.push(\"low recent ratings\");\n }\n }\n\n // Factor 3: Long sessions (avg > 90 min)\n const avgMins = avg(recent.map((s) => s.durationMinutes));\n if (avgMins > 90) {\n risk += 0.15;\n factors.push(\"consistently long sessions\");\n }\n\n // Factor 4: Late-night clustering\n const lateNightCount = recent.filter((s) => s.timePeriod === \"late-night\" || s.timePeriod === \"night\").length;\n if (lateNightCount / recent.length > 0.5) {\n risk += 0.15;\n factors.push(\"frequent late-night sessions\");\n }\n\n // Factor 5: High blocker frequency\n const avgBlockers = avg(recent.map((s) => s.blockers));\n if (avgBlockers > 1) {\n risk += 0.15;\n factors.push(\"frequent blockers\");\n }\n\n // Current session amplifier\n if (currentSession) {\n if (currentSession.minutes > 120 && currentSession.frustration > 0.5) {\n risk += 0.1;\n factors.push(\"current session: long + frustrated\");\n }\n }\n\n risk = clamp(risk, 0, 1);\n\n let recommendation: string | undefined;\n if (risk > 0.7) {\n recommendation = \"Consider taking a longer break. You've been pushing hard — rest is productive too.\";\n } else if (risk > 0.5) {\n recommendation = \"Watch for signs of fatigue. A change of pace or shorter sessions might help.\";\n }\n\n return { risk, factors, recommendation };\n}\n\n// ── Math Utilities ──\n\nfunction clamp(val: number, min: number, max: number): number {\n return Math.max(min, Math.min(max, val));\n}\n\nfunction avg(values: number[]): number {\n if (values.length === 0) return 0;\n return values.reduce((sum, v) => sum + v, 0) / values.length;\n}\n\nfunction ratingSignal(session: SessionSnapshot): number {\n if (session.rating === \"great\") return 1.0;\n if (session.rating === \"good\") return 0.75;\n if (session.rating === \"okay\") return 0.5;\n if (session.rating === \"frustrating\") return 0.25;\n\n // No explicit rating — infer from signals\n let implicit = 1.0;\n implicit -= session.avgFrustration * 0.4;\n implicit -= session.toolErrors > 3 ? 0.2 : 0;\n implicit -= session.blockers > 2 ? 0.2 : 0;\n implicit += session.milestones > 0 ? 0.1 : 0;\n return clamp(implicit, 0, 1);\n}\n\nfunction ratingToNumber(rating?: string): number {\n if (rating === \"great\") return 1.0;\n if (rating === \"good\") return 0.75;\n if (rating === \"okay\") return 0.5;\n if (rating === \"frustrating\") return 0.25;\n return 0.5;\n}\n\nfunction pearsonR(x: number[], y: number[]): number {\n const n = x.length;\n if (n < 3) return 0;\n\n const mx = avg(x);\n const my = avg(y);\n\n let num = 0;\n let dx2 = 0;\n let dy2 = 0;\n\n for (let i = 0; i < n; i++) {\n const dx = x[i] - mx;\n const dy = y[i] - my;\n num += dx * dy;\n dx2 += dx * dx;\n dy2 += dy * dy;\n }\n\n const denom = Math.sqrt(dx2 * dy2);\n if (denom === 0) return 0;\n return num / denom;\n}\n\nfunction computeTrustTrajectory(\n sessions: SessionSnapshot[],\n): \"ascending\" | \"stable\" | \"declining\" {\n if (sessions.length < 10) return \"stable\";\n\n const recent5 = sessions.slice(-5).map(ratingSignal);\n const prev5 = sessions.slice(-10, -5).map(ratingSignal);\n\n const recentAvg = avg(recent5);\n const prevAvg = avg(prev5);\n const delta = recentAvg - prevAvg;\n\n if (delta > 0.1) return \"ascending\";\n if (delta < -0.1) return \"declining\";\n return \"stable\";\n}\n\nfunction computeSentimentTrend(sessions: SessionSnapshot[]): \"improving\" | \"stable\" | \"worsening\" {\n if (sessions.length < 5) return \"stable\";\n\n const frustrations = sessions.slice(-10).map((s) => s.avgFrustration);\n const slope = linearSlope(frustrations);\n\n if (slope > 0.02) return \"worsening\"; // frustration increasing = worsening\n if (slope < -0.02) return \"improving\"; // frustration decreasing = improving\n return \"stable\";\n}\n\nfunction computeLinearTrend(values: number[]): \"increasing\" | \"stable\" | \"decreasing\" {\n if (values.length < 5) return \"stable\";\n\n const recent = values.slice(-10);\n const slope = linearSlope(recent);\n\n // Normalize slope relative to mean to detect meaningful changes\n const mean = avg(recent);\n const relativeSlope = mean > 0 ? slope / mean : slope;\n\n if (relativeSlope > 0.03) return \"increasing\";\n if (relativeSlope < -0.03) return \"decreasing\";\n return \"stable\";\n}\n\nfunction linearSlope(values: number[]): number {\n const n = values.length;\n if (n < 2) return 0;\n\n let sumX = 0;\n let sumY = 0;\n let sumXY = 0;\n let sumX2 = 0;\n\n for (let i = 0; i < n; i++) {\n sumX += i;\n sumY += values[i];\n sumXY += i * values[i];\n sumX2 += i * i;\n }\n\n const denom = n * sumX2 - sumX * sumX;\n if (denom === 0) return 0;\n return (n * sumXY - sumX * sumY) / denom;\n}\n"],"mappings":"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","names":["fs","path","os","fs","path","os","pc","fs","path","os","fs","path","path","fs","path","fs","os","fs","path","os","path","os","fs","fs","path","os","fs","path","os","fs","path","fs","path","os","delegateRemote","recall","pc"]}