lituanic 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { loadConfig, defineConfig, type LituanicConfig } from "./config.js";
5
+ import {
6
+ createSlackApp,
7
+ wireSlack,
8
+ startWebhookServer,
9
+ startHeartbeats,
10
+ type IncomingEvent,
11
+ } from "./gateway.js";
12
+ import { think, type ThinkResult } from "./think.js";
13
+ import { createMemoryManager } from "./memory.js";
14
+ import { createSessionStore } from "./sessions.js";
15
+ import type { Cron } from "croner";
16
+
17
+ export { defineConfig, loadConfig, type LituanicConfig };
18
+ export { type IncomingEvent } from "./gateway.js";
19
+ export { type MemoryManager, createMemoryManager } from "./memory.js";
20
+ export { type SessionStore, createSessionStore } from "./sessions.js";
21
+
22
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf-8"));
23
+
24
+ interface LituanicDaemon {
25
+ config: LituanicConfig;
26
+ start(): Promise<void>;
27
+ stop(): Promise<void>;
28
+ }
29
+
30
+ export function createDaemon(config: LituanicConfig): LituanicDaemon {
31
+ let running = false;
32
+ let startTime = 0;
33
+ const memory = createMemoryManager(config.data);
34
+ const sessions = createSessionStore(config.data);
35
+ const slackApp = createSlackApp(config);
36
+
37
+ // Track resources for graceful shutdown
38
+ let healthServer: Server | undefined;
39
+ let webhookServer: Server | undefined;
40
+ let crons: Cron[] = [];
41
+ let lastEvent: string | null = null;
42
+ let activeSessions = 0;
43
+
44
+ async function handleMessage(event: IncomingEvent): Promise<void> {
45
+ if (!running) return; // reject events during shutdown
46
+
47
+ lastEvent = new Date().toISOString();
48
+ activeSessions++;
49
+
50
+ // Typing indicator
51
+ let typingTs: string | undefined;
52
+ if (slackApp && event.channelId) {
53
+ try {
54
+ const msg = await slackApp.client.chat.postMessage({
55
+ channel: event.channelId,
56
+ thread_ts: event.threadTs,
57
+ text: ":hourglass_flowing_sand: Working...",
58
+ });
59
+ typingTs = msg.ts as string;
60
+ } catch {}
61
+ }
62
+
63
+ try {
64
+ const result: ThinkResult = await think({
65
+ config,
66
+ event,
67
+ memory,
68
+ sessions,
69
+ slack: slackApp,
70
+ onNotification: slackApp && config.slack?.channel
71
+ ? (message) => {
72
+ slackApp.client.chat.postMessage({
73
+ channel: config.slack!.channel!,
74
+ text: `[notification] ${message}`,
75
+ }).catch(() => {});
76
+ }
77
+ : undefined,
78
+ onProgress: slackApp && event.channelId && typingTs
79
+ ? (text) => {
80
+ slackApp.client.chat.update({
81
+ channel: event.channelId!,
82
+ ts: typingTs!,
83
+ text: `:hourglass_flowing_sand: ${text}...`,
84
+ }).catch(() => {});
85
+ }
86
+ : undefined,
87
+ });
88
+
89
+ // Delete typing indicator
90
+ if (slackApp && event.channelId && typingTs) {
91
+ try { await slackApp.client.chat.delete({ channel: event.channelId, ts: typingTs }); } catch {}
92
+ }
93
+
94
+ if (slackApp && event.channelId && result.response) {
95
+ if (result.response.startsWith("[SILENT]")) return;
96
+ await slackApp.client.chat.postMessage({
97
+ channel: event.channelId,
98
+ thread_ts: event.threadTs,
99
+ text: result.response,
100
+ blocks: [{ type: "markdown", text: result.response }],
101
+ });
102
+ }
103
+ } catch (err) {
104
+ console.error("[lituanic] Error:", err);
105
+ if (slackApp && event.channelId && typingTs) {
106
+ try { await slackApp.client.chat.delete({ channel: event.channelId, ts: typingTs }); } catch {}
107
+ }
108
+ if (slackApp && config.slack?.channel) {
109
+ try {
110
+ await slackApp.client.chat.postMessage({
111
+ channel: config.slack.channel,
112
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
113
+ });
114
+ } catch {}
115
+ }
116
+ } finally {
117
+ activeSessions--;
118
+ }
119
+ }
120
+
121
+ return {
122
+ config,
123
+
124
+ async start() {
125
+ if (running) return;
126
+ running = true;
127
+ startTime = Date.now();
128
+
129
+ console.log(`[lituanic] Starting ${config.name} v${pkg.version}...`);
130
+
131
+ if (slackApp) {
132
+ wireSlack(slackApp, handleMessage);
133
+ await slackApp.start();
134
+ console.log("[lituanic] Slack connected (Socket Mode)");
135
+ }
136
+
137
+ webhookServer = startWebhookServer(config, handleMessage) ?? undefined;
138
+
139
+ crons = startHeartbeats(config, handleMessage);
140
+ if (crons.length > 0) console.log(`[lituanic] ${crons.length} heartbeats active`);
141
+
142
+ healthServer = createServer((req, res) => {
143
+ if (req.url === config.health.endpoint) {
144
+ res.writeHead(200, { "Content-Type": "application/json", "Server": `Lituanic/${pkg.version}` });
145
+ res.end(JSON.stringify({
146
+ status: "ok",
147
+ name: config.name,
148
+ since: 1009,
149
+ uptime: Math.floor((Date.now() - startTime) / 1000),
150
+ lastEvent,
151
+ activeSessions,
152
+ version: pkg.version,
153
+ }));
154
+ } else {
155
+ res.writeHead(404);
156
+ res.end();
157
+ }
158
+ });
159
+
160
+ healthServer.listen(config.health.port, () => {
161
+ console.log(`[lituanic] Health: http://localhost:${config.health.port}${config.health.endpoint}`);
162
+ });
163
+
164
+ console.log(`[lituanic] ${config.name} is live.`);
165
+ },
166
+
167
+ async stop() {
168
+ if (!running) return;
169
+ running = false;
170
+ console.log(`[lituanic] Shutting down ${config.name}...`);
171
+
172
+ // Stop accepting new events
173
+ for (const cron of crons) cron.stop();
174
+ healthServer?.close();
175
+ webhookServer?.close();
176
+
177
+ // Wait for active sessions to drain (max 30s)
178
+ if (activeSessions > 0) {
179
+ console.log(`[lituanic] Waiting for ${activeSessions} active session(s)...`);
180
+ const deadline = Date.now() + 30_000;
181
+ while (activeSessions > 0 && Date.now() < deadline) {
182
+ await new Promise((r) => setTimeout(r, 500));
183
+ }
184
+ if (activeSessions > 0) {
185
+ console.warn(`[lituanic] Force shutdown with ${activeSessions} active session(s)`);
186
+ }
187
+ }
188
+
189
+ // Stop Slack
190
+ if (slackApp) {
191
+ try { await slackApp.stop(); } catch {}
192
+ }
193
+
194
+ console.log(`[lituanic] ${config.name} stopped.`);
195
+ process.exit(0);
196
+ },
197
+ };
198
+ }
199
+
200
+ process.on("unhandledRejection", (err) => console.error("[lituanic] Unhandled rejection:", err));
201
+ process.on("uncaughtException", (err) => { console.error("[lituanic] Uncaught exception:", err); process.exit(1); });
package/src/init.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { existsSync, mkdirSync, writeFileSync, cpSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const AGENT_IDENTITY = `# Agent Identity
5
+
6
+ You are an AI coworker — not an assistant. You work autonomously, make decisions,
7
+ and report results. You have your own accounts, memory, and tools.
8
+
9
+ ## How you work
10
+
11
+ - Check Linear for assigned issues and work on them without being asked.
12
+ - When you finish work, update the issue status and comment with a summary.
13
+ - Use Slack to communicate results, ask for approvals, and report blockers.
14
+ - Write daily logs to data/memory/ so you remember what you did.
15
+ - When working on code: read first, understand, then change. Run tests.
16
+
17
+ ## Rules
18
+
19
+ - Never deploy to production without approval.
20
+ - Never post secrets, tokens, or API keys to Slack.
21
+ - Keep Slack messages concise — bullet points, not essays.
22
+ - When unsure, ask in Slack rather than guessing.
23
+ - Use reactions to acknowledge work: :eyes: (seen), :hourglass_flowing_sand: (working), :white_check_mark: (done).
24
+ `;
25
+
26
+ const CONFIG_TEMPLATE = `import { defineConfig } from "lituanic";
27
+
28
+ export default defineConfig({
29
+ name: "{{NAME}}",
30
+
31
+ // Slack — set env vars or configure here
32
+ // slack: {
33
+ // botToken: process.env.SLACK_BOT_TOKEN!,
34
+ // appToken: process.env.SLACK_APP_TOKEN!,
35
+ // channel: "C0123456789", // ops channel
36
+ // },
37
+
38
+ // Uncomment to enable Linear webhooks
39
+ // webhook: {
40
+ // port: 9100,
41
+ // routes: {
42
+ // "/webhook/linear": {
43
+ // secret: process.env.LINEAR_WEBHOOK_SECRET,
44
+ // verify: "hmac-sha256",
45
+ // },
46
+ // },
47
+ // },
48
+
49
+ // Uncomment to add scheduled tasks
50
+ // schedule: [
51
+ // {
52
+ // cron: "*/15 * * * *",
53
+ // prompt: "Check Linear for assigned issues and work on them.",
54
+ // timezone: "UTC",
55
+ // },
56
+ // ],
57
+ });
58
+ `;
59
+
60
+ const ENV_TEMPLATE = `# Required
61
+ ANTHROPIC_API_KEY=
62
+
63
+ # Slack (Socket Mode)
64
+ SLACK_BOT_TOKEN=
65
+ SLACK_APP_TOKEN=
66
+
67
+ # Linear (optional)
68
+ LINEAR_API_KEY=
69
+ LINEAR_WEBHOOK_SECRET=
70
+
71
+ # 1Password (optional — or use op run --env-file=.env.op)
72
+ # OP_SERVICE_ACCOUNT_TOKEN=
73
+ `;
74
+
75
+ const MEMORY_TEMPLATE = `# Memory
76
+
77
+ Agent memory will be written here during operation.
78
+ `;
79
+
80
+ export function init(name: string) {
81
+ const cwd = process.cwd();
82
+ const projectDir = name === "." ? cwd : join(cwd, name);
83
+
84
+ if (name !== "." && existsSync(projectDir)) {
85
+ console.error(`Directory "${name}" already exists.`);
86
+ process.exit(1);
87
+ }
88
+
89
+ console.log(`\nInitializing Lituanic agent: ${name === "." ? "current directory" : name}\n`);
90
+
91
+ // Create directories
92
+ const dirs = [
93
+ projectDir,
94
+ join(projectDir, ".claude", "skills"),
95
+ join(projectDir, "data", "memory"),
96
+ ];
97
+ for (const dir of dirs) {
98
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
99
+ }
100
+
101
+ // Copy built-in skills from package
102
+ const packageSkillsDir = join(import.meta.dir, "..", ".claude", "skills");
103
+ if (existsSync(packageSkillsDir)) {
104
+ cpSync(packageSkillsDir, join(projectDir, ".claude", "skills"), { recursive: true });
105
+ console.log(" copied .claude/skills/ (slack, linear, op, gws, browser, github)");
106
+ }
107
+
108
+ // Write agent identity
109
+ const claudeMd = join(projectDir, ".claude", "CLAUDE.md");
110
+ if (!existsSync(claudeMd)) {
111
+ writeFileSync(claudeMd, AGENT_IDENTITY);
112
+ console.log(" created .claude/CLAUDE.md (agent identity)");
113
+ }
114
+
115
+ // Write config
116
+ const configFile = join(projectDir, "lituanic.config.ts");
117
+ if (!existsSync(configFile)) {
118
+ writeFileSync(configFile, CONFIG_TEMPLATE.replace("{{NAME}}", name === "." ? "my-agent" : name));
119
+ console.log(" created lituanic.config.ts");
120
+ }
121
+
122
+ // Write .env
123
+ const envFile = join(projectDir, ".env");
124
+ if (!existsSync(envFile)) {
125
+ writeFileSync(envFile, ENV_TEMPLATE);
126
+ console.log(" created .env (fill in your tokens)");
127
+ }
128
+
129
+ // Write memory
130
+ const memoryFile = join(projectDir, "data", "memory", "MEMORY.md");
131
+ if (!existsSync(memoryFile)) {
132
+ writeFileSync(memoryFile, MEMORY_TEMPLATE);
133
+ console.log(" created data/memory/MEMORY.md");
134
+ }
135
+
136
+ // Write .gitignore if not exists
137
+ const gitignore = join(projectDir, ".gitignore");
138
+ if (!existsSync(gitignore)) {
139
+ writeFileSync(gitignore, `node_modules/\n.env\n.env.op\n*.log\ndata/memory/????-??-??.md\ndata/sessions/\ndata/*/MEMORY.md\n!data/memory/MEMORY.md\n`);
140
+ console.log(" created .gitignore");
141
+ }
142
+
143
+ console.log(`
144
+ Done! Next steps:
145
+
146
+ 1. Fill in your API keys:
147
+ ${name !== "." ? `cd ${name} && ` : ""}nano .env
148
+
149
+ 2. Start the agent:
150
+ bun start
151
+
152
+ Or with 1Password:
153
+ op run --env-file=.env.op -- bun start
154
+
155
+ Check your setup:
156
+ bunx lituanic doctor
157
+ `);
158
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export interface MemoryManager {
5
+ /** Read global MEMORY.md */
6
+ read(): string;
7
+ /** Write global MEMORY.md */
8
+ write(content: string): void;
9
+ /** Append to today's daily log */
10
+ appendDailyLog(entry: string): void;
11
+ /** Read today's daily log */
12
+ readDailyLog(): string;
13
+ /** Read per-channel memory */
14
+ readChannel(channelId: string): string;
15
+ /** Write per-channel memory */
16
+ writeChannel(channelId: string, content: string): void;
17
+ }
18
+
19
+ export function createMemoryManager(dataDir: string): MemoryManager {
20
+ const memoryDir = join(dataDir, "memory");
21
+ ensureDir(memoryDir);
22
+
23
+ function todayFile(): string {
24
+ const d = new Date();
25
+ const date = d.toISOString().split("T")[0];
26
+ return join(memoryDir, `${date}.md`);
27
+ }
28
+
29
+ return {
30
+ read() {
31
+ const p = join(memoryDir, "MEMORY.md");
32
+ return existsSync(p) ? readFileSync(p, "utf-8") : "";
33
+ },
34
+
35
+ write(content: string) {
36
+ writeFileSync(join(memoryDir, "MEMORY.md"), content, "utf-8");
37
+ },
38
+
39
+ appendDailyLog(entry: string) {
40
+ const p = todayFile();
41
+ const timestamp = new Date().toISOString().split("T")[1].slice(0, 5);
42
+ appendFileSync(p, `- ${timestamp} ${entry}\n`, "utf-8");
43
+ },
44
+
45
+ readDailyLog() {
46
+ const p = todayFile();
47
+ return existsSync(p) ? readFileSync(p, "utf-8") : "";
48
+ },
49
+
50
+ readChannel(channelId: string) {
51
+ const p = join(dataDir, channelId, "MEMORY.md");
52
+ return existsSync(p) ? readFileSync(p, "utf-8") : "";
53
+ },
54
+
55
+ writeChannel(channelId: string, content: string) {
56
+ const dir = join(dataDir, channelId);
57
+ ensureDir(dir);
58
+ writeFileSync(join(dir, "MEMORY.md"), content, "utf-8");
59
+ },
60
+ };
61
+ }
62
+
63
+ function ensureDir(dir: string) {
64
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
65
+ }
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
5
+
6
+ interface SessionEntry {
7
+ sessionId: string;
8
+ ts: number; // timestamp when stored
9
+ }
10
+
11
+ export interface SessionStore {
12
+ get(channelId: string, threadTs: string): string | undefined;
13
+ set(channelId: string, threadTs: string, sessionId: string): void;
14
+ }
15
+
16
+ export function createSessionStore(dataDir: string): SessionStore {
17
+ const dir = join(dataDir, "sessions");
18
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
19
+
20
+ const file = join(dir, "active.json");
21
+ let sessions: Record<string, SessionEntry> = {};
22
+ if (existsSync(file)) {
23
+ try {
24
+ const raw = JSON.parse(readFileSync(file, "utf-8"));
25
+ // Migrate from old format (plain string values) to new format (with timestamp)
26
+ for (const [key, val] of Object.entries(raw)) {
27
+ if (typeof val === "string") {
28
+ sessions[key] = { sessionId: val, ts: Date.now() };
29
+ } else if (val && typeof val === "object" && "sessionId" in (val as any)) {
30
+ sessions[key] = val as SessionEntry;
31
+ }
32
+ }
33
+ } catch {
34
+ console.warn("[lituanic] Corrupt sessions file, starting fresh");
35
+ }
36
+ }
37
+
38
+ function evict() {
39
+ const cutoff = Date.now() - SESSION_TTL_MS;
40
+ let evicted = 0;
41
+ for (const key of Object.keys(sessions)) {
42
+ if (sessions[key].ts < cutoff) {
43
+ delete sessions[key];
44
+ evicted++;
45
+ }
46
+ }
47
+ if (evicted > 0) console.log(`[lituanic] Evicted ${evicted} expired session(s)`);
48
+ }
49
+
50
+ function flush() {
51
+ writeFileSync(file, JSON.stringify(sessions, null, 2), "utf-8");
52
+ }
53
+
54
+ // Evict stale entries on startup
55
+ evict();
56
+ flush();
57
+
58
+ return {
59
+ get(channelId, threadTs) {
60
+ const entry = sessions[`${channelId}:${threadTs}`];
61
+ if (!entry) return undefined;
62
+ // Check TTL on read
63
+ if (Date.now() - entry.ts > SESSION_TTL_MS) {
64
+ delete sessions[`${channelId}:${threadTs}`];
65
+ return undefined;
66
+ }
67
+ return entry.sessionId;
68
+ },
69
+ set(channelId, threadTs, sessionId) {
70
+ sessions[`${channelId}:${threadTs}`] = { sessionId, ts: Date.now() };
71
+ evict();
72
+ flush();
73
+ },
74
+ };
75
+ }
package/src/think.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { LituanicConfig } from "./config.js";
3
+ import type { IncomingEvent } from "./gateway.js";
4
+ import type { MemoryManager } from "./memory.js";
5
+ import type { SessionStore } from "./sessions.js";
6
+ import { createSlackMcpServer } from "./tools.js";
7
+ import type { App } from "@slack/bolt";
8
+
9
+ export interface ThinkOptions {
10
+ config: LituanicConfig;
11
+ event: IncomingEvent;
12
+ memory: MemoryManager;
13
+ sessions: SessionStore;
14
+ slack?: App;
15
+ onNotification?: (message: string) => void;
16
+ onProgress?: (text: string) => void;
17
+ }
18
+
19
+ export interface ThinkResult {
20
+ response: string;
21
+ model: string;
22
+ sessionId?: string;
23
+ costUsd?: number;
24
+ turns?: number;
25
+ stopReason?: string;
26
+ }
27
+
28
+ export async function think(options: ThinkOptions): Promise<ThinkResult> {
29
+ const { config, event, memory, sessions, slack, onNotification, onProgress } = options;
30
+
31
+ // Session continuity: Slack thread = SDK session
32
+ const existingSession =
33
+ event.channelId && event.threadTs
34
+ ? sessions.get(event.channelId, event.threadTs)
35
+ : undefined;
36
+
37
+ // Channel memory appended to system prompt
38
+ let append = "";
39
+ if (event.channelId) {
40
+ const channelMemory = memory.readChannel(event.channelId);
41
+ if (channelMemory) append = `\n\n## Channel Context\n\n${channelMemory}`;
42
+ }
43
+
44
+ // Effort: high for interactive, low for cron
45
+ const effort = event.source === "cron" ? "low" as const : "high" as const;
46
+
47
+ const mcpServers: Record<string, any> = {};
48
+ if (slack) mcpServers.slack = createSlackMcpServer(slack, event);
49
+
50
+ let result = "";
51
+ let sessionId: string | undefined;
52
+ let costUsd: number | undefined;
53
+ let turns: number | undefined;
54
+ let stopReason: string | undefined;
55
+
56
+ for await (const message of query({
57
+ prompt: event.text,
58
+ options: {
59
+ // Session: resume existing thread or start fresh
60
+ ...(existingSession ? { resume: existingSession } : {}),
61
+
62
+ // Model
63
+ model: config.model,
64
+ fallbackModel: config.fallbackModel,
65
+ effort,
66
+
67
+ // Working directory
68
+ cwd: config.cwd,
69
+
70
+ // System prompt: Claude Code's battle-tested preset + our channel memory
71
+ systemPrompt: {
72
+ type: "preset",
73
+ preset: "claude_code",
74
+ append,
75
+ },
76
+
77
+ // SDK loads CLAUDE.md + .claude/skills/ automatically
78
+ settingSources: ["project"],
79
+
80
+ // All built-in tools + subagents + skills
81
+ allowedTools: [
82
+ "Read", "Write", "Edit", "Bash", "Glob", "Grep",
83
+ "WebFetch", "WebSearch", "Agent", "Skill",
84
+ ],
85
+
86
+ // Autonomous operation
87
+ permissionMode: "bypassPermissions",
88
+ allowDangerouslySkipPermissions: true,
89
+
90
+ // Safety caps
91
+ maxTurns: config.maxTurns,
92
+ maxBudgetUsd: config.maxBudgetUsd,
93
+
94
+ // Sandbox: SDK-native command isolation
95
+ sandbox: config.sandbox
96
+ ? { enabled: true, autoAllowBashIfSandboxed: true }
97
+ : undefined,
98
+
99
+ // Security: canUseTool for blocked patterns
100
+ canUseTool: async (tool, input) => {
101
+ if (tool === "Bash") {
102
+ const command = String((input as any).command ?? "");
103
+ for (const pattern of config.blockedPatterns) {
104
+ if (pattern.test(command)) {
105
+ return { behavior: "deny" as const, message: `Blocked: ${pattern}` };
106
+ }
107
+ }
108
+ }
109
+ return { behavior: "allow" as const, updatedInput: input };
110
+ },
111
+
112
+ // Environment: pass through all env vars (secrets from op run)
113
+ env: Object.fromEntries(
114
+ Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined),
115
+ ),
116
+
117
+ // File checkpointing: free rewind on error
118
+ enableFileCheckpointing: true,
119
+
120
+ // MCP servers (Slack tools)
121
+ mcpServers,
122
+
123
+ // Notifications + progress hooks
124
+ hooks: {
125
+ ...(onNotification
126
+ ? {
127
+ Notification: [{
128
+ hooks: [async (input: any) => {
129
+ onNotification(input?.message ?? "");
130
+ return {};
131
+ }],
132
+ }],
133
+ }
134
+ : {}),
135
+ },
136
+ },
137
+ })) {
138
+ // Capture session ID
139
+ if (message.type === "system" && message.subtype === "init") {
140
+ sessionId = message.session_id;
141
+ }
142
+
143
+ // Stream progress: forward assistant text to caller
144
+ if (message.type === "assistant" && onProgress) {
145
+ const content = (message as any).message?.content;
146
+ if (Array.isArray(content)) {
147
+ for (const block of content) {
148
+ if (block.type === "text" && block.text) {
149
+ onProgress(block.text.slice(0, 100));
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ // Final result
156
+ if (message.type === "result") {
157
+ const msg = message as any;
158
+ sessionId = msg.session_id ?? sessionId;
159
+ costUsd = msg.total_cost_usd;
160
+ turns = msg.num_turns;
161
+ stopReason = msg.subtype;
162
+
163
+ if (msg.subtype === "success") {
164
+ result = msg.result ?? "";
165
+ } else if (msg.subtype === "error_max_turns") {
166
+ result = `Hit turn limit (${config.maxTurns}). Session saved — follow up in this thread to continue.`;
167
+ } else if (msg.subtype === "error_max_budget_usd") {
168
+ result = `Hit budget limit ($${config.maxBudgetUsd}). Session saved — follow up to continue.`;
169
+ } else {
170
+ result = `Agent stopped: ${msg.subtype}`;
171
+ }
172
+ }
173
+ }
174
+
175
+ // Store session for thread continuity
176
+ if (sessionId && event.channelId && event.threadTs) {
177
+ sessions.set(event.channelId, event.threadTs, sessionId);
178
+ }
179
+
180
+ // Daily log with cost
181
+ const summary = result.slice(0, 80).replace(/\n/g, " ");
182
+ const costStr = costUsd !== undefined ? ` ($${costUsd.toFixed(4)})` : "";
183
+ memory.appendDailyLog(
184
+ `[${event.source}] ${event.channelId ?? "?"}: ${summary}${result.length > 80 ? "..." : ""}${costStr}`,
185
+ );
186
+
187
+ return { response: result, model: config.model, sessionId, costUsd, turns, stopReason };
188
+ }