talon-agent 1.0.0 → 1.2.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/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- package/src/util/workspace.ts +51 -10
|
@@ -8,16 +8,24 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
10
10
|
import { dirname } from "node:path";
|
|
11
|
+
import { registerCleanup } from "../util/cleanup-registry.js";
|
|
11
12
|
import writeFileAtomic from "write-file-atomic";
|
|
12
13
|
import { log } from "../util/log.js";
|
|
13
14
|
import { files } from "../util/paths.js";
|
|
14
15
|
|
|
15
16
|
export type MediaEntry = {
|
|
16
|
-
id: string;
|
|
17
|
+
id: string; // unique key: chatId:msgId
|
|
17
18
|
chatId: string;
|
|
18
19
|
msgId: number;
|
|
19
20
|
senderName: string;
|
|
20
|
-
type:
|
|
21
|
+
type:
|
|
22
|
+
| "photo"
|
|
23
|
+
| "document"
|
|
24
|
+
| "voice"
|
|
25
|
+
| "video"
|
|
26
|
+
| "animation"
|
|
27
|
+
| "audio"
|
|
28
|
+
| "sticker";
|
|
21
29
|
filePath: string;
|
|
22
30
|
caption?: string;
|
|
23
31
|
timestamp: number;
|
|
@@ -36,7 +44,9 @@ export function loadMediaIndex(): void {
|
|
|
36
44
|
if (existsSync(STORE_FILE)) {
|
|
37
45
|
entries = JSON.parse(readFileSync(STORE_FILE, "utf-8"));
|
|
38
46
|
}
|
|
39
|
-
} catch {
|
|
47
|
+
} catch {
|
|
48
|
+
entries = [];
|
|
49
|
+
}
|
|
40
50
|
// Purge expired on load
|
|
41
51
|
purgeExpired();
|
|
42
52
|
}
|
|
@@ -48,11 +58,13 @@ function save(): void {
|
|
|
48
58
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
49
59
|
writeFileAtomic.sync(STORE_FILE, JSON.stringify(entries) + "\n");
|
|
50
60
|
dirty = false;
|
|
51
|
-
} catch {
|
|
61
|
+
} catch {
|
|
62
|
+
/* non-fatal */
|
|
63
|
+
}
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
const autoSaveTimer = setInterval(save, 30_000);
|
|
55
|
-
|
|
67
|
+
registerCleanup(save);
|
|
56
68
|
|
|
57
69
|
export function flushMediaIndex(): void {
|
|
58
70
|
clearInterval(autoSaveTimer);
|
|
@@ -80,7 +92,11 @@ export function getRecentMedia(chatId: string, limit = 10): MediaEntry[] {
|
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
/** Get all media matching a type in a chat. */
|
|
83
|
-
export function getMediaByType(
|
|
95
|
+
export function getMediaByType(
|
|
96
|
+
chatId: string,
|
|
97
|
+
type: MediaEntry["type"],
|
|
98
|
+
limit = 10,
|
|
99
|
+
): MediaEntry[] {
|
|
84
100
|
return entries
|
|
85
101
|
.filter((e) => e.chatId === chatId && e.type === type)
|
|
86
102
|
.sort((a, b) => b.timestamp - a.timestamp)
|
|
@@ -91,11 +107,16 @@ export function getMediaByType(chatId: string, type: MediaEntry["type"], limit =
|
|
|
91
107
|
export function formatMediaIndex(chatId: string, limit = 10): string {
|
|
92
108
|
const media = getRecentMedia(chatId, limit);
|
|
93
109
|
if (media.length === 0) return "No recent media in this chat.";
|
|
94
|
-
return media
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
return media
|
|
111
|
+
.map((m) => {
|
|
112
|
+
const time = new Date(m.timestamp)
|
|
113
|
+
.toISOString()
|
|
114
|
+
.slice(0, 16)
|
|
115
|
+
.replace("T", " ");
|
|
116
|
+
const cap = m.caption ? ` "${m.caption.slice(0, 50)}"` : "";
|
|
117
|
+
return `[${m.type}] msg:${m.msgId} by ${m.senderName} at ${time}${cap}\n file: ${m.filePath}`;
|
|
118
|
+
})
|
|
119
|
+
.join("\n");
|
|
99
120
|
}
|
|
100
121
|
|
|
101
122
|
// ── Expiry ──────────────────────────────────────────────────────────────────
|
|
@@ -106,7 +127,11 @@ function purgeExpired(): void {
|
|
|
106
127
|
entries = entries.filter((e) => {
|
|
107
128
|
if (e.timestamp >= cutoff) return true;
|
|
108
129
|
// Delete the file too
|
|
109
|
-
try {
|
|
130
|
+
try {
|
|
131
|
+
if (existsSync(e.filePath)) unlinkSync(e.filePath);
|
|
132
|
+
} catch {
|
|
133
|
+
/* skip */
|
|
134
|
+
}
|
|
110
135
|
return false;
|
|
111
136
|
});
|
|
112
137
|
if (entries.length < before) {
|
package/src/storage/sessions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import writeFileAtomic from "write-file-atomic";
|
|
|
3
3
|
import { log, logError } from "../util/log.js";
|
|
4
4
|
import { recordError } from "../util/watchdog.js";
|
|
5
5
|
import { dirs, files } from "../util/paths.js";
|
|
6
|
+
import { registerCleanup } from "../util/cleanup-registry.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Session manager — maps Telegram chat IDs to Claude SDK session IDs.
|
|
@@ -227,7 +228,7 @@ export function recordUsage(
|
|
|
227
228
|
session.usage.totalResponseMs =
|
|
228
229
|
(session.usage.totalResponseMs || 0) + turn.durationMs;
|
|
229
230
|
session.usage.lastResponseMs = turn.durationMs;
|
|
230
|
-
const current = session.usage.fastestResponseMs
|
|
231
|
+
const current = session.usage.fastestResponseMs;
|
|
231
232
|
if (turn.durationMs < current) {
|
|
232
233
|
session.usage.fastestResponseMs = turn.durationMs;
|
|
233
234
|
}
|
|
@@ -318,7 +319,7 @@ export function getAllSessions(): Array<{ chatId: string; info: SessionInfo }> {
|
|
|
318
319
|
}
|
|
319
320
|
|
|
320
321
|
// Flush on exit (signal handlers are in index.ts for graceful shutdown)
|
|
321
|
-
|
|
322
|
+
registerCleanup(saveSessions);
|
|
322
323
|
|
|
323
324
|
/** Force-save sessions to disk and stop the auto-save timer. */
|
|
324
325
|
export function flushSessions(): void {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized process exit handler registry.
|
|
3
|
+
*
|
|
4
|
+
* Each storage module calls registerCleanup() instead of
|
|
5
|
+
* process.on("exit", fn) directly. This keeps exactly ONE
|
|
6
|
+
* "exit" listener on the process regardless of how many modules
|
|
7
|
+
* are loaded — avoiding MaxListenersExceededWarning.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const handlers: Array<() => void> = [];
|
|
11
|
+
let registered = false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register a synchronous cleanup function to run on process exit.
|
|
15
|
+
* Safe to call multiple times across multiple modules — only one
|
|
16
|
+
* process "exit" listener is ever registered.
|
|
17
|
+
*/
|
|
18
|
+
export function registerCleanup(fn: () => void): void {
|
|
19
|
+
handlers.push(fn);
|
|
20
|
+
if (!registered) {
|
|
21
|
+
registered = true;
|
|
22
|
+
process.on("exit", runAll);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runAll(): void {
|
|
27
|
+
for (const fn of handlers) {
|
|
28
|
+
try {
|
|
29
|
+
fn();
|
|
30
|
+
} catch {
|
|
31
|
+
// Suppress — we're in exit, can't do much about it
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/util/config.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
2
8
|
import { resolve } from "node:path";
|
|
3
9
|
import writeFileAtomic from "write-file-atomic";
|
|
4
10
|
import { z } from "zod";
|
|
5
11
|
import { dirs, files as pathFiles } from "./paths.js";
|
|
6
|
-
import { setTimezone, formatFullDatetime } from "./time.js";
|
|
12
|
+
import { setTimezone, formatFullDatetime, todayAndYesterday } from "./time.js";
|
|
7
13
|
import { log } from "./log.js";
|
|
8
14
|
|
|
9
|
-
|
|
10
15
|
// ── Config schema ───────────────────────────────────────────────────────────
|
|
11
16
|
|
|
12
17
|
const pluginEntrySchema = z.object({
|
|
@@ -28,8 +33,12 @@ const configSchema = z.object({
|
|
|
28
33
|
apiId: z.number().int().optional(),
|
|
29
34
|
apiHash: z.string().optional(),
|
|
30
35
|
adminUserId: z.number().int().optional(),
|
|
36
|
+
allowedUsers: z.array(z.number().int()).optional(), // Whitelist of user IDs allowed to DM the bot
|
|
31
37
|
pulse: z.boolean().default(true),
|
|
32
38
|
pulseIntervalMs: z.number().int().min(60000).default(300000),
|
|
39
|
+
heartbeat: z.boolean().default(false),
|
|
40
|
+
heartbeatIntervalMinutes: z.number().int().min(5).default(60),
|
|
41
|
+
heartbeatModel: z.string().optional(), // Model for heartbeat agent (defaults to main model)
|
|
33
42
|
braveApiKey: z.string().optional(),
|
|
34
43
|
searxngUrl: z.string().default("http://localhost:8080"),
|
|
35
44
|
timezone: z.string().optional(),
|
|
@@ -77,7 +86,9 @@ function loadConfigFile(): Record<string, unknown> {
|
|
|
77
86
|
if (existsSync(CONFIG_FILE)) {
|
|
78
87
|
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
79
88
|
}
|
|
80
|
-
} catch {
|
|
89
|
+
} catch {
|
|
90
|
+
/* corrupt — will be recreated */
|
|
91
|
+
}
|
|
81
92
|
return {};
|
|
82
93
|
}
|
|
83
94
|
|
|
@@ -89,7 +100,10 @@ function ensureConfigFile(): boolean {
|
|
|
89
100
|
if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
|
|
90
101
|
if (!existsSync(dirs.data)) mkdirSync(dirs.data, { recursive: true });
|
|
91
102
|
if (!existsSync(CONFIG_FILE)) {
|
|
92
|
-
writeFileAtomic.sync(
|
|
103
|
+
writeFileAtomic.sync(
|
|
104
|
+
CONFIG_FILE,
|
|
105
|
+
JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
|
|
106
|
+
);
|
|
93
107
|
return true;
|
|
94
108
|
}
|
|
95
109
|
return false;
|
|
@@ -100,13 +114,18 @@ function ensureConfigFile(): boolean {
|
|
|
100
114
|
function readOptionalFile(path: string): string {
|
|
101
115
|
try {
|
|
102
116
|
if (existsSync(path)) return readFileSync(path, "utf-8").trim();
|
|
103
|
-
} catch {
|
|
117
|
+
} catch {
|
|
118
|
+
/* ignore */
|
|
119
|
+
}
|
|
104
120
|
return "";
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
let lastLoggedPromptKey = "";
|
|
108
124
|
|
|
109
|
-
function loadSystemPrompt(
|
|
125
|
+
function loadSystemPrompt(
|
|
126
|
+
frontend?: string,
|
|
127
|
+
pluginPromptAdditions?: string[],
|
|
128
|
+
): string {
|
|
110
129
|
const promptDir = dirs.prompts;
|
|
111
130
|
const parts: string[] = [];
|
|
112
131
|
|
|
@@ -124,14 +143,21 @@ function loadSystemPrompt(frontend?: string, pluginPromptAdditions?: string[]):
|
|
|
124
143
|
// Load base prompt (shared across all frontends)
|
|
125
144
|
const custom = readOptionalFile(resolve(promptDir, "custom.md"));
|
|
126
145
|
const basePrompt = readOptionalFile(resolve(promptDir, "base.md"));
|
|
127
|
-
if (custom) {
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
if (custom) {
|
|
147
|
+
parts.push(custom);
|
|
148
|
+
loaded.push("custom");
|
|
149
|
+
} else if (basePrompt) {
|
|
150
|
+
parts.push(basePrompt);
|
|
151
|
+
loaded.push("base");
|
|
152
|
+
} else parts.push("You are a sharp and helpful AI assistant.");
|
|
130
153
|
|
|
131
154
|
// Load frontend-specific prompt
|
|
132
155
|
const frontendFile = `${frontend ?? "telegram"}.md`;
|
|
133
156
|
const frontendPrompt = readOptionalFile(resolve(promptDir, frontendFile));
|
|
134
|
-
if (frontendPrompt) {
|
|
157
|
+
if (frontendPrompt) {
|
|
158
|
+
parts.push(frontendPrompt);
|
|
159
|
+
loaded.push(frontendFile.replace(".md", ""));
|
|
160
|
+
}
|
|
135
161
|
|
|
136
162
|
const memory = readOptionalFile(pathFiles.memory);
|
|
137
163
|
if (memory) {
|
|
@@ -141,6 +167,12 @@ function loadSystemPrompt(frontend?: string, pluginPromptAdditions?: string[]):
|
|
|
141
167
|
loaded.push("memory");
|
|
142
168
|
}
|
|
143
169
|
|
|
170
|
+
// Point the bot at daily memory files (read on demand, not injected)
|
|
171
|
+
const { today } = todayAndYesterday();
|
|
172
|
+
parts.push(
|
|
173
|
+
`## 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.`,
|
|
174
|
+
);
|
|
175
|
+
|
|
144
176
|
const loadedKey = loaded.join(" + ");
|
|
145
177
|
if (loadedKey && loadedKey !== lastLoggedPromptKey) {
|
|
146
178
|
log("config", `System prompt: ${loadedKey}`);
|
|
@@ -155,28 +187,44 @@ function loadSystemPrompt(frontend?: string, pluginPromptAdditions?: string[]):
|
|
|
155
187
|
const entries: string[] = [];
|
|
156
188
|
try {
|
|
157
189
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
158
|
-
if (
|
|
190
|
+
if (
|
|
191
|
+
e.name.startsWith(".") ||
|
|
192
|
+
e.name === "node_modules" ||
|
|
193
|
+
e.name === "talon.log"
|
|
194
|
+
)
|
|
195
|
+
continue;
|
|
159
196
|
const full = resolve(dir, e.name);
|
|
160
197
|
if (e.isDirectory()) {
|
|
161
198
|
const sub = listDir(full, `${prefix}${e.name}/`);
|
|
162
199
|
if (sub.length > 0 && sub.length <= 8) entries.push(...sub);
|
|
163
|
-
else if (sub.length > 8)
|
|
200
|
+
else if (sub.length > 8)
|
|
201
|
+
entries.push(`${prefix}${e.name}/ (${sub.length} files)`);
|
|
164
202
|
} else {
|
|
165
203
|
const sz = statSync(full).size;
|
|
166
|
-
entries.push(
|
|
204
|
+
entries.push(
|
|
205
|
+
`${prefix}${e.name} (${sz < 1024 ? sz + "B" : (sz / 1024).toFixed(0) + "KB"})`,
|
|
206
|
+
);
|
|
167
207
|
}
|
|
168
208
|
}
|
|
169
|
-
} catch {
|
|
209
|
+
} catch {
|
|
210
|
+
/* skip */
|
|
211
|
+
}
|
|
170
212
|
return entries;
|
|
171
213
|
};
|
|
172
214
|
const files = listDir(workspaceDir);
|
|
173
|
-
if (files.length > 0)
|
|
174
|
-
|
|
215
|
+
if (files.length > 0)
|
|
216
|
+
workspaceFiles =
|
|
217
|
+
"\n\nCurrent workspace contents:\n" +
|
|
218
|
+
files.map((f) => ` ${f}`).join("\n");
|
|
219
|
+
} catch {
|
|
220
|
+
/* no workspace yet */
|
|
221
|
+
}
|
|
175
222
|
|
|
176
223
|
parts.push(`## Workspace
|
|
177
224
|
|
|
178
225
|
You have a workspace directory at \`~/.talon/workspace/\`. This is your home — organize it however you want.
|
|
179
226
|
- \`~/.talon/workspace/memory/memory.md\` is your persistent memory file. Update it when you learn important things.
|
|
227
|
+
- \`~/.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.
|
|
180
228
|
- Daily interaction logs are saved to \`~/.talon/workspace/logs/\` automatically.
|
|
181
229
|
- Files users send you (photos, docs, voice) are saved to \`~/.talon/workspace/uploads/\`.
|
|
182
230
|
- Persistent cron jobs are managed via the cron tools.
|
|
@@ -215,13 +263,19 @@ export function loadConfig(): TalonConfig {
|
|
|
215
263
|
setTimezone(parsed.timezone);
|
|
216
264
|
|
|
217
265
|
// Validate per-frontend requirements
|
|
218
|
-
const frontends = Array.isArray(parsed.frontend)
|
|
266
|
+
const frontends = Array.isArray(parsed.frontend)
|
|
267
|
+
? parsed.frontend
|
|
268
|
+
: [parsed.frontend];
|
|
219
269
|
for (const fe of frontends) {
|
|
220
270
|
if (fe === "telegram" && !parsed.botToken) {
|
|
221
|
-
throw new Error(
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Telegram frontend requires "botToken" in ${CONFIG_FILE}. Run "talon setup" to configure.`,
|
|
273
|
+
);
|
|
222
274
|
}
|
|
223
275
|
if (fe === "teams" && !parsed.teamsWebhookUrl) {
|
|
224
|
-
throw new Error(
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Teams frontend requires "teamsWebhookUrl" in ${CONFIG_FILE}. Run "talon setup" to configure.`,
|
|
278
|
+
);
|
|
225
279
|
}
|
|
226
280
|
}
|
|
227
281
|
|
|
@@ -238,7 +292,15 @@ export function loadConfig(): TalonConfig {
|
|
|
238
292
|
* Rebuild the system prompt with plugin additions.
|
|
239
293
|
* Called after plugins are loaded to inject their prompt contributions.
|
|
240
294
|
*/
|
|
241
|
-
export function rebuildSystemPrompt(
|
|
242
|
-
|
|
243
|
-
|
|
295
|
+
export function rebuildSystemPrompt(
|
|
296
|
+
config: TalonConfig,
|
|
297
|
+
pluginAdditions: string[],
|
|
298
|
+
): void {
|
|
299
|
+
const frontends = Array.isArray(config.frontend)
|
|
300
|
+
? config.frontend
|
|
301
|
+
: [config.frontend];
|
|
302
|
+
config.systemPrompt = loadSystemPrompt(
|
|
303
|
+
frontends[0],
|
|
304
|
+
pluginAdditions.length > 0 ? pluginAdditions : undefined,
|
|
305
|
+
);
|
|
244
306
|
}
|
package/src/util/log.ts
CHANGED
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import pino from "pino";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
statSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
} from "node:fs";
|
|
12
19
|
import { dirs, files } from "./paths.js";
|
|
13
20
|
|
|
14
21
|
export type LogComponent =
|
|
@@ -22,22 +29,29 @@ export type LogComponent =
|
|
|
22
29
|
| "workspace"
|
|
23
30
|
| "shutdown"
|
|
24
31
|
| "file"
|
|
32
|
+
| "history"
|
|
25
33
|
| "sessions"
|
|
26
34
|
| "settings"
|
|
27
35
|
| "commands"
|
|
28
36
|
| "cron"
|
|
29
37
|
| "dream"
|
|
38
|
+
| "heartbeat"
|
|
30
39
|
| "dispatcher"
|
|
31
40
|
| "gateway"
|
|
32
41
|
| "plugin"
|
|
33
42
|
| "teams"
|
|
34
|
-
| "config"
|
|
43
|
+
| "config"
|
|
44
|
+
| "access";
|
|
35
45
|
|
|
36
46
|
const LOG_FILE = files.log;
|
|
37
47
|
|
|
38
48
|
// Ensure .talon dir exists for log file
|
|
39
49
|
if (!existsSync(dirs.root)) {
|
|
40
|
-
try {
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(dirs.root, { recursive: true });
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
41
55
|
}
|
|
42
56
|
|
|
43
57
|
// Rotate log file on startup if it exceeds 10MB
|
|
@@ -45,10 +59,21 @@ const MAX_LOG_SIZE = 10 * 1024 * 1024;
|
|
|
45
59
|
try {
|
|
46
60
|
if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > MAX_LOG_SIZE) {
|
|
47
61
|
const rotated = `${LOG_FILE}.old`;
|
|
48
|
-
try {
|
|
62
|
+
try {
|
|
63
|
+
unlinkSync(rotated);
|
|
64
|
+
} catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
49
67
|
renameSync(LOG_FILE, rotated);
|
|
50
68
|
}
|
|
51
|
-
} catch {
|
|
69
|
+
} catch {
|
|
70
|
+
/* ignore */
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Detect if running as a bun compiled binary (pino-pretty can't be bundled).
|
|
74
|
+
// import.meta.path is Bun-specific — undefined in Node.js/Vitest, so guard with ?.
|
|
75
|
+
const isBunBinary =
|
|
76
|
+
(import.meta as { path?: string }).path?.startsWith("/$bunfs/") ?? false;
|
|
52
77
|
|
|
53
78
|
// Suppress console output for terminal frontend (stdout belongs to the REPL)
|
|
54
79
|
let quiet = process.env.TALON_QUIET === "1";
|
|
@@ -59,23 +84,29 @@ if (!quiet) {
|
|
|
59
84
|
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
60
85
|
if (cfg.frontend === "terminal") quiet = true;
|
|
61
86
|
}
|
|
62
|
-
} catch {
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore */
|
|
89
|
+
}
|
|
63
90
|
}
|
|
64
91
|
|
|
65
92
|
const logger = pino({
|
|
66
93
|
level: "trace",
|
|
67
94
|
transport: {
|
|
68
95
|
targets: [
|
|
69
|
-
// Console output (disabled in quiet mode)
|
|
70
|
-
...(!quiet
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
// Console output (disabled in quiet mode or compiled binary)
|
|
97
|
+
...(!quiet && !isBunBinary
|
|
98
|
+
? [
|
|
99
|
+
{
|
|
100
|
+
target: "pino-pretty",
|
|
101
|
+
level: "trace" as const,
|
|
102
|
+
options: {
|
|
103
|
+
colorize: true,
|
|
104
|
+
ignore: "pid,hostname",
|
|
105
|
+
translateTime: "HH:MM:ss",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
: []),
|
|
79
110
|
// JSON file output (always active)
|
|
80
111
|
{
|
|
81
112
|
target: "pino/file",
|
|
@@ -119,4 +150,3 @@ export function logDebug(component: LogComponent, message: string): void {
|
|
|
119
150
|
(globalThis as Record<string, unknown>).__talonLog = log;
|
|
120
151
|
(globalThis as Record<string, unknown>).__talonLogError = logError;
|
|
121
152
|
(globalThis as Record<string, unknown>).__talonLogWarn = logWarn;
|
|
122
|
-
|
package/src/util/paths.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* media-index.json
|
|
15
15
|
* workspace/ User-facing workspace (memory, uploads, logs)
|
|
16
16
|
* memory/
|
|
17
|
+
* daily/ Per-day memory notes (YYYY-MM-DD.md)
|
|
17
18
|
* uploads/
|
|
18
19
|
* stickers/
|
|
19
20
|
* logs/
|
|
@@ -42,6 +43,8 @@ export const dirs = {
|
|
|
42
43
|
logs: resolve(TALON_ROOT, "workspace", "logs"),
|
|
43
44
|
/** Memory: ~/.talon/workspace/memory/ */
|
|
44
45
|
memory: resolve(TALON_ROOT, "workspace", "memory"),
|
|
46
|
+
/** Daily memory notes: ~/.talon/workspace/memory/daily/ */
|
|
47
|
+
dailyMemory: resolve(TALON_ROOT, "workspace", "memory", "daily"),
|
|
45
48
|
/** Sticker packs: ~/.talon/workspace/stickers/ */
|
|
46
49
|
stickers: resolve(TALON_ROOT, "workspace", "stickers"),
|
|
47
50
|
/** Prompt files: ~/.talon/prompts/ */
|
|
@@ -77,4 +80,11 @@ export const files = {
|
|
|
77
80
|
pid: resolve(TALON_ROOT, "talon.pid"),
|
|
78
81
|
/** Dream mode state: ~/.talon/workspace/memory/dream_state.json */
|
|
79
82
|
dreamState: resolve(TALON_ROOT, "workspace", "memory", "dream_state.json"),
|
|
83
|
+
/** Heartbeat state: ~/.talon/workspace/memory/heartbeat_state.json */
|
|
84
|
+
heartbeatState: resolve(
|
|
85
|
+
TALON_ROOT,
|
|
86
|
+
"workspace",
|
|
87
|
+
"memory",
|
|
88
|
+
"heartbeat_state.json",
|
|
89
|
+
),
|
|
80
90
|
} as const;
|
package/src/util/time.ts
CHANGED
|
@@ -29,12 +29,12 @@ function toHHMM(date: Date): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** Format a Date in the configured timezone as YYYY-MM-DD. */
|
|
32
|
-
function toYMD(date: Date): string {
|
|
32
|
+
export function toYMD(date: Date): string {
|
|
33
33
|
return date.toLocaleDateString("en-CA", { timeZone: getTimezone() }); // en-CA gives YYYY-MM-DD
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** Get "today" and "yesterday" date strings in the configured timezone. */
|
|
37
|
-
function todayAndYesterday(): { today: string; yesterday: string } {
|
|
37
|
+
export function todayAndYesterday(): { today: string; yesterday: string } {
|
|
38
38
|
const now = new Date();
|
|
39
39
|
const today = toYMD(now);
|
|
40
40
|
const yd = new Date(now.getTime() - 86_400_000);
|
|
@@ -60,18 +60,38 @@ export function formatSmartTimestamp(ts: number): string {
|
|
|
60
60
|
if (dateStr === yesterday) return `Yesterday ${time}`;
|
|
61
61
|
|
|
62
62
|
const now = new Date();
|
|
63
|
-
const thisYear = now
|
|
63
|
+
const thisYear = now
|
|
64
|
+
.toLocaleDateString("en-CA", { timeZone: getTimezone() })
|
|
65
|
+
.slice(0, 4);
|
|
64
66
|
const msgYear = dateStr.slice(0, 4);
|
|
65
67
|
|
|
66
68
|
if (msgYear === thisYear) {
|
|
67
|
-
const month = date.toLocaleString("en-US", {
|
|
68
|
-
|
|
69
|
+
const month = date.toLocaleString("en-US", {
|
|
70
|
+
month: "short",
|
|
71
|
+
timeZone: getTimezone(),
|
|
72
|
+
});
|
|
73
|
+
const day = date.toLocaleString("en-US", {
|
|
74
|
+
day: "numeric",
|
|
75
|
+
timeZone: getTimezone(),
|
|
76
|
+
});
|
|
69
77
|
return `${month} ${day} ${time}`;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
return `${dateStr} ${time}`;
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Human-readable relative age: "just now", "5m ago", "3h ago", "2d ago".
|
|
85
|
+
* Used for user-facing displays where a precise timestamp isn't needed.
|
|
86
|
+
*/
|
|
87
|
+
export function formatRelativeAge(ts: number): string {
|
|
88
|
+
const diff = Date.now() - ts;
|
|
89
|
+
if (diff < 60_000) return "just now";
|
|
90
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
91
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
92
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
/**
|
|
76
96
|
* Full datetime for system prompt injection.
|
|
77
97
|
* Example: "2026-03-21 14:32 (Europe/Warsaw, Fri)"
|
|
@@ -81,6 +101,9 @@ export function formatFullDatetime(): string {
|
|
|
81
101
|
const tz = getTimezone();
|
|
82
102
|
const dateStr = toYMD(now);
|
|
83
103
|
const time = toHHMM(now);
|
|
84
|
-
const weekday = now.toLocaleString("en-US", {
|
|
104
|
+
const weekday = now.toLocaleString("en-US", {
|
|
105
|
+
weekday: "short",
|
|
106
|
+
timeZone: tz,
|
|
107
|
+
});
|
|
85
108
|
return `${dateStr} ${time} ${weekday} (${tz})`;
|
|
86
109
|
}
|
package/src/util/watchdog.ts
CHANGED
|
@@ -69,7 +69,11 @@ export function startWatchdog(workspaceDir?: string): void {
|
|
|
69
69
|
// Ensure workspace still exists (might have been deleted externally)
|
|
70
70
|
if (workspaceDir && !existsSync(workspaceDir)) {
|
|
71
71
|
logWarn("watchdog", "Workspace directory missing — recreating");
|
|
72
|
-
try {
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
74
|
+
} catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
73
77
|
}
|
|
74
78
|
}, 60_000); // Check every minute
|
|
75
79
|
}
|