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/.claude/skills/browser/SKILL.md +140 -0
- package/.claude/skills/github/SKILL.md +143 -0
- package/.claude/skills/gws/SKILL.md +98 -0
- package/.claude/skills/linear/SKILL.md +126 -0
- package/.claude/skills/op/SKILL.md +62 -0
- package/.claude/skills/slack/SKILL.md +25 -0
- package/LICENSE +21 -0
- package/README.md +378 -0
- package/package.json +64 -0
- package/src/__tests__/config.test.ts +106 -0
- package/src/__tests__/sessions.test.ts +85 -0
- package/src/__tests__/smoke.test.ts +28 -0
- package/src/cli.ts +87 -0
- package/src/config.ts +137 -0
- package/src/doctor.ts +117 -0
- package/src/gateway.ts +299 -0
- package/src/index.ts +201 -0
- package/src/init.ts +158 -0
- package/src/memory.ts +65 -0
- package/src/sessions.ts +75 -0
- package/src/think.ts +188 -0
- package/src/tools.ts +40 -0
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
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -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
|
+
}
|