Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions prompts/SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Сгенерируй краткое самари текущей сессии в markdown.

Включи:
- Ключевые решения, которые были приняты
- Незавершённые задачи и их текущий статус
- Важный контекст, который нужно передать в следующую сессию
- Ошибки или проблемы, которые были обнаружены

Формат: заголовки ## с пунктами. Максимум 500 слов. Пиши на русском.
15 changes: 15 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const DEFAULT_SETTINGS: Settings = {
security: { level: "moderate", allowedTools: [], disallowedTools: [] },
web: { enabled: false, host: "127.0.0.1", port: 4632 },
stt: { baseUrl: "", model: "" },
session: { autoRotate: true, maxMessages: 50, maxAgeHours: 24, summaryPath: "" },
};

export interface HeartbeatExcludeWindow {
Expand Down Expand Up @@ -113,6 +114,7 @@ export interface Settings {
security: SecurityConfig;
web: WebConfig;
stt: SttConfig;
session: SessionConfig;
}

export interface AgenticMode {
Expand Down Expand Up @@ -148,6 +150,13 @@ export interface SttConfig {
model: string;
}

export interface SessionConfig {
autoRotate: boolean;
maxMessages: number;
maxAgeHours: number;
summaryPath: string;
}

let cached: Settings | null = null;

export async function initConfig(): Promise<void> {
Expand Down Expand Up @@ -276,6 +285,12 @@ function parseSettings(raw: Record<string, any>): Settings {
baseUrl: typeof raw.stt?.baseUrl === "string" ? raw.stt.baseUrl.trim() : "",
model: typeof raw.stt?.model === "string" ? raw.stt.model.trim() : "",
},
session: {
autoRotate: raw.session?.autoRotate ?? true,
maxMessages: Number.isFinite(raw.session?.maxMessages) ? Number(raw.session.maxMessages) : 50,
maxAgeHours: Number.isFinite(raw.session?.maxAgeHours) ? Number(raw.session.maxAgeHours) : 24,
summaryPath: typeof raw.session?.summaryPath === "string" ? raw.session.summaryPath.trim() : "",
},
};
}

Expand Down
111 changes: 111 additions & 0 deletions src/rotation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
import { peekSession, backupSession, resetSession } from "./sessions";
import type { GlobalSession } from "./sessions";
import type { SessionConfig } from "./config";

const PROMPTS_DIR = join(import.meta.dir, "..", "prompts");
const SUMMARY_PROMPT_FILE = join(PROMPTS_DIR, "SUMMARY.md");

export function needsRotation(session: GlobalSession, sessionConfig: SessionConfig): boolean {
if (!sessionConfig.autoRotate) return false;

if (session.messageCount >= sessionConfig.maxMessages) return true;

const ageMs = Date.now() - new Date(session.createdAt).getTime();
if (ageMs >= sessionConfig.maxAgeHours * 3600000) return true;

return false;
}

export async function rotateSession(sessionConfig: SessionConfig): Promise<void> {
const session = await peekSession();
if (!session) return;

console.log(
`[${new Date().toLocaleTimeString()}] Rotating session ${session.sessionId.slice(0, 8)} (messages: ${session.messageCount}, age: ${Math.round((Date.now() - new Date(session.createdAt).getTime()) / 3600000)}h)`
);

// Generate summary if summaryPath is configured
if (sessionConfig.summaryPath) {
try {
await generateSummary(session.sessionId, sessionConfig.summaryPath);
} catch (e) {
console.error(`[${new Date().toLocaleTimeString()}] Failed to generate session summary:`, e);
// Continue with rotation even if summary fails
}
}

const backupName = await backupSession();
if (backupName) {
console.log(`[${new Date().toLocaleTimeString()}] Session backed up as ${backupName}`);
}

await resetSession();
console.log(`[${new Date().toLocaleTimeString()}] Session rotated — next message will create a new session`);
}

async function generateSummary(sessionId: string, summaryPath: string): Promise<void> {
await mkdir(summaryPath, { recursive: true });

let summaryPrompt: string;
try {
summaryPrompt = await Bun.file(SUMMARY_PROMPT_FILE).text();
} catch {
summaryPrompt = "Generate a brief session summary in markdown. Include: key decisions, unfinished tasks, important context for the next session. Max 500 words.";
}

const { CLAUDECODE: _, ...cleanEnv } = process.env;

const proc = Bun.spawn(
["claude", "-p", summaryPrompt, "--resume", sessionId, "--output-format", "text"],
{
stdout: "pipe",
stderr: "pipe",
env: { ...cleanEnv } as Record<string, string>,
}
);

const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;

if (proc.exitCode !== 0 || !stdout.trim()) {
console.error(`[${new Date().toLocaleTimeString()}] Summary generation failed (exit ${proc.exitCode}):`, stderr);
return;
}

const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const filename = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}-${pad(now.getMinutes())}.md`;
const filepath = join(summaryPath, filename);

await Bun.write(filepath, stdout.trim() + "\n");
console.log(`[${new Date().toLocaleTimeString()}] Session summary saved: ${filepath}`);
}

export async function loadLatestSummary(summaryPath: string): Promise<string | null> {
if (!summaryPath || !existsSync(summaryPath)) return null;

const glob = new Bun.Glob("*.md");
const files: string[] = [];
for await (const file of glob.scan({ cwd: summaryPath })) {
files.push(file);
}

if (files.length === 0) return null;

// Sort by filename (date-based) descending, take latest
files.sort().reverse();
const latest = join(summaryPath, files[0]);

try {
const content = await Bun.file(latest).text();
return content.trim() || null;
} catch {
return null;
}
}
19 changes: 17 additions & 2 deletions src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { mkdir, readFile, writeFile } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
import { getSession, createSession, incrementTurn, markCompactWarned } from "./sessions";
import { getSession, createSession, peekSession, incrementTurn, markCompactWarned } from "./sessions";
import { getSettings, type ModelConfig, type SecurityConfig } from "./config";
import { needsRotation, rotateSession, loadLatestSummary } from "./rotation";
import { buildClockPromptPrefix } from "./timezone";
import { selectModel } from "./model-router";

Expand Down Expand Up @@ -331,6 +332,13 @@ export async function compactCurrentSession(): Promise<{ success: boolean; messa
async function execClaude(name: string, prompt: string): Promise<RunResult> {
await mkdir(LOGS_DIR, { recursive: true });

// Check if session needs rotation before proceeding
const { session: sessionConfig } = getSettings();
const peeked = await peekSession();
if (peeked && needsRotation(peeked, sessionConfig)) {
await rotateSession(sessionConfig);
}

const existing = await getSession();
const isNew = !existing;
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
Expand Down Expand Up @@ -542,6 +550,13 @@ export async function bootstrap(): Promise<void> {
if (existing) return;

console.log(`[${new Date().toLocaleTimeString()}] Bootstrapping new session...`);
await execClaude("bootstrap", "Wakeup, my friend!");

const { session: sessionConfig } = getSettings();
const summary = loadLatestSummary(sessionConfig.summaryPath);
const wakeupPrompt = summary
? `Wakeup, my friend!\n\nКонтекст предыдущей сессии:\n\n${await summary}`
: "Wakeup, my friend!";

await execClaude("bootstrap", wakeupPrompt);
console.log(`[${new Date().toLocaleTimeString()}] Bootstrap complete — session is live.`);
}
1 change: 1 addition & 0 deletions src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export async function getSession(): Promise<{ sessionId: string; turnCount: numb
if (typeof existing.turnCount !== "number") existing.turnCount = 0;
if (typeof existing.compactWarned !== "boolean") existing.compactWarned = false;
existing.lastUsedAt = new Date().toISOString();
existing.messageCount = (existing.messageCount || 0) + 1;
await saveSession(existing);
return { sessionId: existing.sessionId, turnCount: existing.turnCount, compactWarned: existing.compactWarned };
}
Expand Down