Skip to content
Merged
1 change: 1 addition & 0 deletions prompts/dream.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep).
- Add new entries where appropriate
- Keep entries concise and factual — no padding, no narrative
- Preserve all existing structure and sections
- Also write daily memory summaries to `{{dailyMemoryDir}}/YYYY-MM-DD.md` for each day of logs you processed. Include key learnings, conversation summaries, and follow-ups. Keep these concise — the bot reads them on demand for context.

### Stage 4 — Prune

Expand Down
4 changes: 3 additions & 1 deletion prompts/heartbeat.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep).
- Logs directory: `{{logsDir}}`
- Last heartbeat: `{{lastRunIso}}`
- Run number: #{{runCount}}
- Today's daily memory: `{{dailyMemoryFile}}`

## Instructions

Expand All @@ -18,7 +19,8 @@ If the instructions file does not exist or is empty, perform these default tasks

1. **Review recent logs** — Check `{{logsDir}}/` for log files dated after `{{lastRunIso}}`. If `{{lastRunIso}}` is `never`, treat it as the beginning of time and review all available logs. Extract any new facts, preferences, or notable events.
2. **Update memory** — Merge any new information into `{{memoryFile}}`, keeping entries concise and factual.
3. **Workspace hygiene** — Note any issues but do not delete files unless the instructions explicitly say to.
3. **Update daily notes** — Write today's learnings, observations, corrections, and follow-ups to `{{dailyMemoryFile}}`. Keep entries concise — the bot reads this file on demand for context.
4. **Workspace hygiene** — Note any issues but do not delete files unless the instructions explicitly say to.

## Rules

Expand Down
84 changes: 84 additions & 0 deletions src/__tests__/daily-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock("../util/log.js", () => ({
}));
import { join } from "node:path";
import { tmpdir } from "node:os";
import { toYMD } from "../util/time.js";

// Use a unique temp directory for each test run
const TEST_ROOT = join(tmpdir(), `talon-daily-log-test-${Date.now()}`);
Expand Down Expand Up @@ -156,6 +157,89 @@ describe("daily-log", () => {
});
});

describe("cleanupOldLogs — daily memory files", () => {
const DAILY_MEM_DIR = join(
TEST_ROOT,
".talon",
"workspace",
"memory",
"daily",
);

it("deletes daily memory files older than 30 days", async () => {
Comment thread
dylanneve1 marked this conversation as resolved.
const { cleanupOldLogs } = await import("../storage/daily-log.js");
mkdirSync(LOGS_DIR, { recursive: true });
mkdirSync(DAILY_MEM_DIR, { recursive: true });

// Create an old daily memory file (40 days ago)
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 40);
const oldName = toYMD(oldDate) + ".md";
writeFileSync(join(DAILY_MEM_DIR, oldName), "old daily memory");

// Create a recent daily memory file (5 days ago)
const recentDate = new Date();
recentDate.setDate(recentDate.getDate() - 5);
const recentName = toYMD(recentDate) + ".md";
writeFileSync(join(DAILY_MEM_DIR, recentName), "recent daily memory");
Comment thread
dylanneve1 marked this conversation as resolved.

cleanupOldLogs();

const remaining = readdirSync(DAILY_MEM_DIR);
expect(remaining).not.toContain(oldName);
expect(remaining).toContain(recentName);
});

it("handles missing daily memory directory gracefully", async () => {
const { cleanupOldLogs } = await import("../storage/daily-log.js");
// Create logs dir but NOT the daily memory dir
mkdirSync(LOGS_DIR, { recursive: true });
expect(() => cleanupOldLogs()).not.toThrow();
});

it("ignores non-YYYY-MM-DD.md files in daily memory dir", async () => {
const { cleanupOldLogs } = await import("../storage/daily-log.js");
mkdirSync(LOGS_DIR, { recursive: true });
mkdirSync(DAILY_MEM_DIR, { recursive: true });

// A file that would sort before the cutoff but doesn't match the pattern
writeFileSync(join(DAILY_MEM_DIR, "2020-summary.md"), "should survive");
writeFileSync(join(DAILY_MEM_DIR, "notes.md"), "also should survive");

cleanupOldLogs();

const remaining = readdirSync(DAILY_MEM_DIR);
expect(remaining).toContain("2020-summary.md");
expect(remaining).toContain("notes.md");
});

it("ignores non-YYYY-MM-DD.md files in logs dir", async () => {
const { cleanupOldLogs } = await import("../storage/daily-log.js");
mkdirSync(LOGS_DIR, { recursive: true });

writeFileSync(join(LOGS_DIR, "2020-summary.md"), "should survive");

cleanupOldLogs();

expect(readdirSync(LOGS_DIR)).toContain("2020-summary.md");
});

it("does not delete recent daily memory files", async () => {
const { cleanupOldLogs } = await import("../storage/daily-log.js");
mkdirSync(LOGS_DIR, { recursive: true });
mkdirSync(DAILY_MEM_DIR, { recursive: true });

const recentDate = new Date();
recentDate.setDate(recentDate.getDate() - 3);
const recentName = toYMD(recentDate) + ".md";
writeFileSync(join(DAILY_MEM_DIR, recentName), "keep me");

cleanupOldLogs();

expect(readdirSync(DAILY_MEM_DIR)).toContain(recentName);
});
});

describe("appendDailyLogResponse", () => {
it("writes bot response with chat title context", async () => {
const { appendDailyLogResponse, getLogsDir } =
Expand Down
15 changes: 15 additions & 0 deletions src/__tests__/dream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ vi.mock("../util/paths.js", () => ({
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));

Expand Down Expand Up @@ -180,6 +181,7 @@ describe("forceDream", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));

Expand Down Expand Up @@ -215,6 +217,7 @@ describe("readDreamState — edge cases", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));
});
Expand Down Expand Up @@ -465,6 +468,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));

Expand Down Expand Up @@ -510,6 +514,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));

Expand Down Expand Up @@ -555,6 +560,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));

Expand Down Expand Up @@ -606,6 +612,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
Expand Down Expand Up @@ -649,6 +656,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));
const queryMock = vi.fn(async function* () {});
Expand Down Expand Up @@ -702,6 +710,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
Expand Down Expand Up @@ -753,6 +762,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));

Expand Down Expand Up @@ -796,6 +806,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
Expand Down Expand Up @@ -844,6 +855,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({
Expand Down Expand Up @@ -892,6 +904,7 @@ describe("dream error paths", () => {
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));
const queryMock = vi.fn(async function* () {});
Expand Down Expand Up @@ -938,6 +951,7 @@ describe("runDreamAgent — timeout arrow fn fires after DREAM_TIMEOUT_MS", () =
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));
// query never resolves — so the 10-minute timeout wins the race
Expand Down Expand Up @@ -995,6 +1009,7 @@ describe("maybeStartDream — () => {} catch callback on executeDream rejection"
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
},
}));

Expand Down
1 change: 1 addition & 0 deletions src/__tests__/heartbeat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
workspace: "/fake/.talon/workspace",
data: "/fake/.talon/data",
memory: "/fake/.talon/workspace/memory",
dailyMemory: "/fake/.talon/workspace/memory/daily",
prompts: "/fake/.talon/prompts",
},
}));
Expand Down Expand Up @@ -195,9 +196,9 @@
);

// Make agent throw
queryMock.mockImplementationOnce(async function* () {
throw new Error("Agent exploded");
});

Check warning on line 201 in src/__tests__/heartbeat.test.ts

View workflow job for this annotation

GitHub Actions / Code Quality

eslint(require-yield)

This generator function does not have `yield`

await expect(forceHeartbeat()).rejects.toThrow("Agent exploded");

Expand All @@ -215,9 +216,9 @@
it("sets last_started even on failure", async () => {
existsSyncMock.mockReturnValue(false);

queryMock.mockImplementationOnce(async function* () {
throw new Error("Boom");
});

Check warning on line 221 in src/__tests__/heartbeat.test.ts

View workflow job for this annotation

GitHub Actions / Code Quality

eslint(require-yield)

This generator function does not have `yield`

await expect(forceHeartbeat()).rejects.toThrow("Boom");

Expand Down Expand Up @@ -338,9 +339,9 @@
resolveAgent = r;
});

queryMock.mockImplementationOnce(async function* () {
await agentPromise;
});

Check warning on line 344 in src/__tests__/heartbeat.test.ts

View workflow job for this annotation

GitHub Actions / Code Quality

eslint(require-yield)

This generator function does not have `yield`

const runPromise = forceHeartbeat().catch(() => {});

Expand Down
3 changes: 2 additions & 1 deletion src/core/dream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ async function runDreamAgent(lastRunTimestamp: number): Promise<string> {
.replace(/\{\{dreamStateFile\}\}/g, dreamStateFile)
.replace(/\{\{logsDir\}\}/g, logsDir)
.replace(/\{\{lastRunIso\}\}/g, lastRunIso)
.replace(/\{\{memoryFile\}\}/g, memoryFile);
.replace(/\{\{memoryFile\}\}/g, memoryFile)
.replace(/\{\{dailyMemoryDir\}\}/g, dirs.dailyMemory);
} catch {
throw new Error(`Failed to read dream prompt from ${promptPath}`);
}
Expand Down
3 changes: 3 additions & 0 deletions src/core/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { files as pathFiles, dirs } from "../util/paths.js";
import { log, logError, logWarn } from "../util/log.js";
import { toYMD } from "../util/time.js";

// ── Types ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -240,6 +241,7 @@ async function runHeartbeatAgent(
const memoryFile = pathFiles.memory;
const workspace = configRef.workspace ?? dirs.workspace;
const instructionsFile = resolve(workspace, "heartbeat-instructions.md");
const dailyMemoryFile = resolve(dirs.dailyMemory, `${toYMD(new Date())}.md`);

// Load prompt template from the prompts directory (seeded to ~/.talon/prompts/)
const promptPath = resolve(dirs.prompts, "heartbeat.md");
Expand All @@ -252,6 +254,7 @@ async function runHeartbeatAgent(
.replace(/\{\{lastRunIso\}\}/g, lastRunIso)
.replace(/\{\{memoryFile\}\}/g, memoryFile)
.replace(/\{\{instructionsFile\}\}/g, instructionsFile)
.replace(/\{\{dailyMemoryFile\}\}/g, dailyMemoryFile)
.replace(/\{\{runCount\}\}/g, String(runCount))
.replace(/\{\{intervalMinutes\}\}/g, String(intervalMinutesRef));
} catch {
Expand Down
54 changes: 42 additions & 12 deletions src/storage/daily-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { resolve } from "node:path";
import { log as logInfo, logError } from "../util/log.js";
import { dirs } from "../util/paths.js";
import { toYMD } from "../util/time.js";

const LOGS_DIR = dirs.logs;
const MAX_LOG_DAYS = 30; // Keep last 30 days of logs
Expand Down Expand Up @@ -91,28 +92,57 @@ export function getLogsDir(): string {
return LOGS_DIR;
}

/** Matches YYYY-MM-DD.md filenames strictly. */
const DAILY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;

/** Remove daily logs older than MAX_LOG_DAYS. Called on startup. */
export function cleanupOldLogs(): void {
try {
if (!existsSync(LOGS_DIR)) return;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - MAX_LOG_DAYS);
const cutoffStr = cutoff.toISOString().slice(0, 10);
if (existsSync(LOGS_DIR)) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - MAX_LOG_DAYS);
const cutoffStr = cutoff.toISOString().slice(0, 10);

Comment thread
dylanneve1 marked this conversation as resolved.
let deleted = 0;
for (const file of readdirSync(LOGS_DIR)) {
if (DAILY_FILE_RE.test(file) && file < cutoffStr) {
try {
unlinkSync(resolve(LOGS_DIR, file));
deleted++;
} catch {
/* skip */
}
}
}
if (deleted > 0) {
logInfo("workspace", `Cleaned up ${deleted} old daily log(s)`);
}
}
} catch {
/* skip */
}

// Clean up old daily memory files (independent of logs dir)
try {
const dailyMemDir = dirs.dailyMemory;
if (!existsSync(dailyMemDir)) return;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - MAX_LOG_DAYS);
const cutoffMem = toYMD(cutoffDate);

let deleted = 0;
for (const file of readdirSync(LOGS_DIR)) {
// Log files are named YYYY-MM-DD.md
if (file.endsWith(".md") && file < cutoffStr) {
let deletedMem = 0;
for (const file of readdirSync(dailyMemDir)) {
if (DAILY_FILE_RE.test(file) && file < cutoffMem) {
try {
Comment thread
dylanneve1 marked this conversation as resolved.
unlinkSync(resolve(LOGS_DIR, file));
deleted++;
unlinkSync(resolve(dailyMemDir, file));
Comment thread
dylanneve1 marked this conversation as resolved.
deletedMem++;
} catch {
/* skip */
}
}
}
if (deleted > 0) {
logInfo("workspace", `Cleaned up ${deleted} old daily log(s)`);
if (deletedMem > 0) {
logInfo("workspace", `Cleaned up ${deletedMem} old daily memory file(s)`);
}
} catch {
/* skip */
Expand Down
9 changes: 8 additions & 1 deletion src/util/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { resolve } from "node:path";
import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { dirs, files as pathFiles } from "./paths.js";
import { setTimezone, formatFullDatetime } from "./time.js";
import { setTimezone, formatFullDatetime, todayAndYesterday } from "./time.js";
import { log } from "./log.js";

// ── Config schema ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -166,6 +166,12 @@ function loadSystemPrompt(
loaded.push("memory");
}

// Point the bot at daily memory files (read on demand, not injected)
const { today } = todayAndYesterday();
parts.push(
`## Daily Memory\n\nYour daily notes are stored in \`${dirs.dailyMemory}/\`. Today's file is \`${today}.md\`. Use the Read tool to check recent daily notes when you need context from previous days.`,
);

const loadedKey = loaded.join(" + ");
if (loadedKey && loadedKey !== lastLoggedPromptKey) {
log("config", `System prompt: ${loadedKey}`);
Expand Down Expand Up @@ -217,6 +223,7 @@ function loadSystemPrompt(

You have a workspace directory at \`~/.talon/workspace/\`. This is your home — organize it however you want.
- \`~/.talon/workspace/memory/memory.md\` is your persistent memory file. Update it when you learn important things.
- \`~/.talon/workspace/memory/daily/YYYY-MM-DD.md\` is your daily notes file. Write observations, learnings, corrections, and follow-ups here throughout the day. Keep entries concise.
- Daily interaction logs are saved to \`~/.talon/workspace/logs/\` automatically.
- Files users send you (photos, docs, voice) are saved to \`~/.talon/workspace/uploads/\`.
- Persistent cron jobs are managed via the cron tools.
Expand Down
Loading
Loading