talon-agent 1.0.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.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Structured logging via pino — console + file output.
3
+ *
4
+ * Always runs at trace level (maximum verbosity) for debugging.
5
+ * Logs to both:
6
+ * - stdout (pretty-printed for readability)
7
+ * - workspace/talon.log (JSON, append-only, for persistence)
8
+ */
9
+
10
+ import pino from "pino";
11
+ import { existsSync, readFileSync, mkdirSync, statSync, renameSync, unlinkSync } from "node:fs";
12
+ import { dirs, files } from "./paths.js";
13
+
14
+ export type LogComponent =
15
+ | "bot"
16
+ | "bridge"
17
+ | "agent"
18
+ | "pulse"
19
+ | "userbot"
20
+ | "users"
21
+ | "watchdog"
22
+ | "workspace"
23
+ | "shutdown"
24
+ | "file"
25
+ | "sessions"
26
+ | "settings"
27
+ | "commands"
28
+ | "cron"
29
+ | "dream"
30
+ | "dispatcher"
31
+ | "gateway"
32
+ | "plugin"
33
+ | "teams"
34
+ | "config";
35
+
36
+ const LOG_FILE = files.log;
37
+
38
+ // Ensure .talon dir exists for log file
39
+ if (!existsSync(dirs.root)) {
40
+ try { mkdirSync(dirs.root, { recursive: true }); } catch { /* ignore */ }
41
+ }
42
+
43
+ // Rotate log file on startup if it exceeds 10MB
44
+ const MAX_LOG_SIZE = 10 * 1024 * 1024;
45
+ try {
46
+ if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > MAX_LOG_SIZE) {
47
+ const rotated = `${LOG_FILE}.old`;
48
+ try { unlinkSync(rotated); } catch { /* ignore */ }
49
+ renameSync(LOG_FILE, rotated);
50
+ }
51
+ } catch { /* ignore */ }
52
+
53
+ // Suppress console output for terminal frontend (stdout belongs to the REPL)
54
+ let quiet = process.env.TALON_QUIET === "1";
55
+ if (!quiet) {
56
+ try {
57
+ const cfgPath = files.config;
58
+ if (existsSync(cfgPath)) {
59
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
60
+ if (cfg.frontend === "terminal") quiet = true;
61
+ }
62
+ } catch { /* ignore */ }
63
+ }
64
+
65
+ const logger = pino({
66
+ level: "trace",
67
+ transport: {
68
+ targets: [
69
+ // Console output (disabled in quiet mode)
70
+ ...(!quiet ? [{
71
+ target: "pino-pretty",
72
+ level: "trace" as const,
73
+ options: {
74
+ colorize: true,
75
+ ignore: "pid,hostname",
76
+ translateTime: "HH:MM:ss",
77
+ },
78
+ }] : []),
79
+ // JSON file output (always active)
80
+ {
81
+ target: "pino/file",
82
+ level: "trace",
83
+ options: {
84
+ destination: LOG_FILE,
85
+ mkdir: true,
86
+ },
87
+ },
88
+ ],
89
+ },
90
+ });
91
+
92
+ export function log(component: LogComponent, message: string): void {
93
+ logger.info({ component }, message);
94
+ }
95
+
96
+ export function logError(
97
+ component: LogComponent,
98
+ message: string,
99
+ err?: unknown,
100
+ ): void {
101
+ if (err instanceof Error) {
102
+ logger.error({ component, err: err.message }, message);
103
+ } else if (err !== undefined) {
104
+ logger.error({ component, err: String(err) }, message);
105
+ } else {
106
+ logger.error({ component }, message);
107
+ }
108
+ }
109
+
110
+ export function logWarn(component: LogComponent, message: string): void {
111
+ logger.warn({ component }, message);
112
+ }
113
+
114
+ export function logDebug(component: LogComponent, message: string): void {
115
+ logger.debug({ component }, message);
116
+ }
117
+
118
+ // Expose logger to plugins running in the same process
119
+ (globalThis as Record<string, unknown>).__talonLog = log;
120
+ (globalThis as Record<string, unknown>).__talonLogError = logError;
121
+ (globalThis as Record<string, unknown>).__talonLogWarn = logWarn;
122
+
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Centralized path resolution for all Talon directories and files.
3
+ *
4
+ * Location: ~/.talon/ (cross-platform: Linux, macOS, Windows)
5
+ *
6
+ * Layout:
7
+ * ~/.talon/
8
+ * config.json Main configuration
9
+ * data/ Internal state (sessions, history, settings, cron, media)
10
+ * sessions.json
11
+ * history.json
12
+ * chat-settings.json
13
+ * cron.json
14
+ * media-index.json
15
+ * workspace/ User-facing workspace (memory, uploads, logs)
16
+ * memory/
17
+ * uploads/
18
+ * stickers/
19
+ * logs/
20
+ * talon.log Structured log file
21
+ * .user-session Telegram userbot session
22
+ */
23
+
24
+ import { resolve } from "node:path";
25
+ import { homedir } from "node:os";
26
+
27
+ /** Root of the Talon data directory: ~/.talon/ */
28
+ const TALON_ROOT = resolve(homedir(), ".talon");
29
+
30
+ // ── Directories ────────────────────────────────────────────────────────────
31
+
32
+ export const dirs = {
33
+ /** Root: ~/.talon/ */
34
+ root: TALON_ROOT,
35
+ /** Internal data: ~/.talon/data/ */
36
+ data: resolve(TALON_ROOT, "data"),
37
+ /** User workspace: ~/.talon/workspace/ */
38
+ workspace: resolve(TALON_ROOT, "workspace"),
39
+ /** Upload files: ~/.talon/workspace/uploads/ */
40
+ uploads: resolve(TALON_ROOT, "workspace", "uploads"),
41
+ /** Daily logs: ~/.talon/workspace/logs/ */
42
+ logs: resolve(TALON_ROOT, "workspace", "logs"),
43
+ /** Memory: ~/.talon/workspace/memory/ */
44
+ memory: resolve(TALON_ROOT, "workspace", "memory"),
45
+ /** Sticker packs: ~/.talon/workspace/stickers/ */
46
+ stickers: resolve(TALON_ROOT, "workspace", "stickers"),
47
+ /** Prompt files: ~/.talon/prompts/ */
48
+ prompts: resolve(TALON_ROOT, "prompts"),
49
+ /** Per-chat message traces: ~/.talon/data/traces/ */
50
+ traces: resolve(TALON_ROOT, "data", "traces"),
51
+ } as const;
52
+
53
+ // ── Files ──────────────────────────────────────────────────────────────────
54
+
55
+ export const files = {
56
+ /** Main config: ~/.talon/config.json */
57
+ config: resolve(TALON_ROOT, "config.json"),
58
+ /** Structured log: ~/.talon/talon.log */
59
+ log: resolve(TALON_ROOT, "talon.log"),
60
+ /** Session store: ~/.talon/data/sessions.json */
61
+ sessions: resolve(TALON_ROOT, "data", "sessions.json"),
62
+ /** Chat history: ~/.talon/data/history.json */
63
+ history: resolve(TALON_ROOT, "data", "history.json"),
64
+ /** Per-chat settings: ~/.talon/data/chat-settings.json */
65
+ chatSettings: resolve(TALON_ROOT, "data", "chat-settings.json"),
66
+ /** Cron jobs: ~/.talon/data/cron.json */
67
+ cron: resolve(TALON_ROOT, "data", "cron.json"),
68
+ /** Media index: ~/.talon/data/media-index.json */
69
+ mediaIndex: resolve(TALON_ROOT, "data", "media-index.json"),
70
+ /** Persistent memory: ~/.talon/workspace/memory/memory.md */
71
+ memory: resolve(TALON_ROOT, "workspace", "memory", "memory.md"),
72
+ /** Self-bootstrapping identity: ~/.talon/workspace/identity.md */
73
+ identity: resolve(TALON_ROOT, "workspace", "identity.md"),
74
+ /** Telegram userbot session: ~/.talon/.user-session */
75
+ userSession: resolve(TALON_ROOT, ".user-session"),
76
+ /** PID file for daemon mode: ~/.talon/talon.pid */
77
+ pid: resolve(TALON_ROOT, "talon.pid"),
78
+ /** Dream mode state: ~/.talon/workspace/memory/dream_state.json */
79
+ dreamState: resolve(TALON_ROOT, "workspace", "memory", "dream_state.json"),
80
+ } as const;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Timezone-aware time formatting utilities.
3
+ *
4
+ * All functions accept a timezone string (IANA, e.g. "Europe/Warsaw").
5
+ * If none is set, falls back to the system default.
6
+ */
7
+
8
+ let configuredTz: string | undefined;
9
+
10
+ /** Set the global timezone used by all formatting helpers. */
11
+ export function setTimezone(tz: string | undefined): void {
12
+ configuredTz = tz;
13
+ }
14
+
15
+ /** Get the active timezone (configured or system default). */
16
+ export function getTimezone(): string {
17
+ return configuredTz ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
18
+ }
19
+
20
+ // ── Formatting helpers ──────────────────────────────────────────────────────
21
+
22
+ /** Format a Date in the configured timezone as HH:MM. */
23
+ function toHHMM(date: Date): string {
24
+ return date.toLocaleTimeString("en-GB", {
25
+ hour: "2-digit",
26
+ minute: "2-digit",
27
+ timeZone: getTimezone(),
28
+ });
29
+ }
30
+
31
+ /** Format a Date in the configured timezone as YYYY-MM-DD. */
32
+ function toYMD(date: Date): string {
33
+ return date.toLocaleDateString("en-CA", { timeZone: getTimezone() }); // en-CA gives YYYY-MM-DD
34
+ }
35
+
36
+ /** Get "today" and "yesterday" date strings in the configured timezone. */
37
+ function todayAndYesterday(): { today: string; yesterday: string } {
38
+ const now = new Date();
39
+ const today = toYMD(now);
40
+ const yd = new Date(now.getTime() - 86_400_000);
41
+ const yesterday = toYMD(yd);
42
+ return { today, yesterday };
43
+ }
44
+
45
+ /**
46
+ * Smart timestamp for message display.
47
+ *
48
+ * - Today: "14:32"
49
+ * - Yesterday: "Yesterday 14:32"
50
+ * - This year: "Mar 19 14:32"
51
+ * - Older: "2025-12-19 14:32"
52
+ */
53
+ export function formatSmartTimestamp(ts: number): string {
54
+ const date = new Date(ts);
55
+ const time = toHHMM(date);
56
+ const dateStr = toYMD(date);
57
+ const { today, yesterday } = todayAndYesterday();
58
+
59
+ if (dateStr === today) return time;
60
+ if (dateStr === yesterday) return `Yesterday ${time}`;
61
+
62
+ const now = new Date();
63
+ const thisYear = now.toLocaleDateString("en-CA", { timeZone: getTimezone() }).slice(0, 4);
64
+ const msgYear = dateStr.slice(0, 4);
65
+
66
+ if (msgYear === thisYear) {
67
+ const month = date.toLocaleString("en-US", { month: "short", timeZone: getTimezone() });
68
+ const day = date.toLocaleString("en-US", { day: "numeric", timeZone: getTimezone() });
69
+ return `${month} ${day} ${time}`;
70
+ }
71
+
72
+ return `${dateStr} ${time}`;
73
+ }
74
+
75
+ /**
76
+ * Full datetime for system prompt injection.
77
+ * Example: "2026-03-21 14:32 (Europe/Warsaw, Fri)"
78
+ */
79
+ export function formatFullDatetime(): string {
80
+ const now = new Date();
81
+ const tz = getTimezone();
82
+ const dateStr = toYMD(now);
83
+ const time = toHHMM(now);
84
+ const weekday = now.toLocaleString("en-US", { weekday: "short", timeZone: tz });
85
+ return `${dateStr} ${time} ${weekday} (${tz})`;
86
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-chat message trace — dumps full in/out messages to ~/.talon/data/traces/<chatId>.jsonl
3
+ * for debugging. One JSON object per line, append-only.
4
+ */
5
+
6
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { dirs } from "./paths.js";
9
+
10
+ function ensureDir(): void {
11
+ if (!existsSync(dirs.traces)) mkdirSync(dirs.traces, { recursive: true });
12
+ }
13
+
14
+ export function traceMessage(
15
+ chatId: string,
16
+ direction: "in" | "out",
17
+ text: string,
18
+ meta?: Record<string, unknown>,
19
+ ): void {
20
+ try {
21
+ ensureDir();
22
+ const entry = {
23
+ ts: new Date().toISOString(),
24
+ dir: direction,
25
+ text,
26
+ ...meta,
27
+ };
28
+ appendFileSync(
29
+ resolve(dirs.traces, `${chatId}.jsonl`),
30
+ JSON.stringify(entry) + "\n",
31
+ );
32
+ } catch {
33
+ // Non-fatal — never break the bot for debug tracing
34
+ }
35
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Watchdog -- tracks bot health and recent errors.
3
+ * Monitors message processing activity and bridge HTTP server responsiveness.
4
+ */
5
+
6
+ import { existsSync, mkdirSync } from "node:fs";
7
+ import { logWarn } from "./log.js";
8
+
9
+ // ── Message processing tracking ──────────────────────────────────────────────
10
+
11
+ let lastProcessedAt = Date.now();
12
+ let totalMessagesProcessed = 0;
13
+ const startTime = Date.now();
14
+
15
+ /** Record that a message was successfully processed. */
16
+ export function recordMessageProcessed(): void {
17
+ lastProcessedAt = Date.now();
18
+ totalMessagesProcessed++;
19
+ }
20
+
21
+ /** Get total messages processed since startup. */
22
+ export function getTotalMessagesProcessed(): number {
23
+ return totalMessagesProcessed;
24
+ }
25
+
26
+ /** Get bot uptime in milliseconds. */
27
+ export function getUptimeMs(): number {
28
+ return Date.now() - startTime;
29
+ }
30
+
31
+ // ── Error tracking ───────────────────────────────────────────────────────────
32
+
33
+ type ErrorRecord = {
34
+ message: string;
35
+ timestamp: number;
36
+ };
37
+
38
+ const recentErrors: ErrorRecord[] = [];
39
+ const MAX_ERRORS = 20;
40
+
41
+ /** Record an error for admin visibility. */
42
+ export function recordError(message: string): void {
43
+ recentErrors.push({ message, timestamp: Date.now() });
44
+ if (recentErrors.length > MAX_ERRORS) {
45
+ recentErrors.splice(0, recentErrors.length - MAX_ERRORS);
46
+ }
47
+ }
48
+
49
+ /** Get the last N errors. */
50
+ export function getRecentErrors(limit = 5): ErrorRecord[] {
51
+ return recentErrors.slice(-limit);
52
+ }
53
+
54
+ // ── Inactivity monitoring ────────────────────────────────────────────────────
55
+
56
+ const INACTIVITY_WARN_MS = 10 * 60 * 1000; // 10 minutes
57
+ let watchdogTimer: ReturnType<typeof setInterval> | null = null;
58
+
59
+ export function startWatchdog(workspaceDir?: string): void {
60
+ if (watchdogTimer) return;
61
+
62
+ watchdogTimer = setInterval(() => {
63
+ const elapsed = Date.now() - lastProcessedAt;
64
+ if (totalMessagesProcessed > 0 && elapsed > INACTIVITY_WARN_MS) {
65
+ const mins = Math.round(elapsed / 60000);
66
+ logWarn("watchdog", `No messages processed for ${mins} minutes`);
67
+ }
68
+
69
+ // Ensure workspace still exists (might have been deleted externally)
70
+ if (workspaceDir && !existsSync(workspaceDir)) {
71
+ logWarn("watchdog", "Workspace directory missing — recreating");
72
+ try { mkdirSync(workspaceDir, { recursive: true }); } catch { /* ignore */ }
73
+ }
74
+ }, 60_000); // Check every minute
75
+ }
76
+
77
+ export function stopWatchdog(): void {
78
+ if (watchdogTimer) {
79
+ clearInterval(watchdogTimer);
80
+ watchdogTimer = null;
81
+ }
82
+ }
83
+
84
+ // ── Health check ─────────────────────────────────────────────────────────────
85
+
86
+ export type HealthStatus = {
87
+ healthy: boolean;
88
+ uptimeMs: number;
89
+ totalMessagesProcessed: number;
90
+ lastProcessedAt: number;
91
+ msSinceLastMessage: number;
92
+ recentErrorCount: number;
93
+ };
94
+
95
+ /** Get current health status (exportable for external monitoring). */
96
+ export function getHealthStatus(): HealthStatus {
97
+ const now = Date.now();
98
+ const msSinceLastMessage = now - lastProcessedAt;
99
+ return {
100
+ // Unhealthy if no messages processed for 30+ minutes when we've seen at least one
101
+ healthy: totalMessagesProcessed === 0 || msSinceLastMessage < 30 * 60_000,
102
+ uptimeMs: now - startTime,
103
+ totalMessagesProcessed,
104
+ lastProcessedAt,
105
+ msSinceLastMessage,
106
+ recentErrorCount: recentErrors.length,
107
+ };
108
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Workspace — Claude's home directory.
3
+ * Talon only ensures the root exists. Claude organizes it however it wants.
4
+ * Includes periodic cleanup of old uploads to prevent disk exhaustion.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readdirSync, rmdirSync, renameSync, statSync, unlinkSync, copyFileSync, cpSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { log } from "./log.js";
10
+ import { dirs, files as pathFiles } from "./paths.js";
11
+
12
+ const IDENTITY_SEED = `# Identity
13
+
14
+ <!-- This file defines who you are. It's empty because you're new here. -->
15
+ <!-- On your first conversation, ask the user to help you fill this in: -->
16
+ <!-- - What should I be called? -->
17
+ <!-- - Who are you? Who created me? -->
18
+ <!-- - What will I be used for? -->
19
+ <!-- Then write your identity here using the Write tool. Keep it concise. -->
20
+ `;
21
+
22
+ // ── Layout migration ────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Migrate from the old workspace/ layout to the new .talon/ layout.
26
+ * Only runs if workspace/ exists and .talon/ does not.
27
+ * Uses renameSync (same filesystem, atomic).
28
+ */
29
+ export function migrateLayout(): void {
30
+ const oldRoot = resolve(process.cwd(), "workspace");
31
+ if (!existsSync(oldRoot) || existsSync(dirs.root)) return;
32
+
33
+ log("workspace", "Migrating workspace/ → .talon/ layout");
34
+
35
+ // Create target directories
36
+ mkdirSync(dirs.data, { recursive: true });
37
+ mkdirSync(dirs.workspace, { recursive: true });
38
+
39
+ // File moves: old → new
40
+ const fileMoves: Array<[string, string]> = [
41
+ [join(oldRoot, "talon.json"), dirs.root + "/config.json"],
42
+ [join(oldRoot, "sessions.json"), join(dirs.data, "sessions.json")],
43
+ [join(oldRoot, "history.json"), join(dirs.data, "history.json")],
44
+ [join(oldRoot, "chat-settings.json"), join(dirs.data, "chat-settings.json")],
45
+ [join(oldRoot, "cron.json"), join(dirs.data, "cron.json")],
46
+ [join(oldRoot, "media-index.json"), join(dirs.data, "media-index.json")],
47
+ [join(oldRoot, "talon.log"), join(dirs.root, "talon.log")],
48
+ [join(oldRoot, ".user-session"), join(dirs.root, ".user-session")],
49
+ ];
50
+
51
+ // Move helper — try rename first (fast, same filesystem), fall back to copy+delete
52
+ const moveFile = (src: string, dst: string) => {
53
+ try {
54
+ renameSync(src, dst);
55
+ } catch {
56
+ // Cross-filesystem: copy then delete
57
+ copyFileSync(src, dst);
58
+ unlinkSync(src);
59
+ }
60
+ log("workspace", `Moved ${src} → ${dst}`);
61
+ };
62
+
63
+ for (const [src, dst] of fileMoves) {
64
+ if (existsSync(src)) moveFile(src, dst);
65
+ }
66
+
67
+ // Directory moves: old → new
68
+ const dirMoves: Array<[string, string]> = [
69
+ [join(oldRoot, "memory"), join(dirs.workspace, "memory")],
70
+ [join(oldRoot, "uploads"), join(dirs.workspace, "uploads")],
71
+ [join(oldRoot, "logs"), join(dirs.workspace, "logs")],
72
+ [join(oldRoot, "stickers"), join(dirs.workspace, "stickers")],
73
+ ];
74
+
75
+ for (const [src, dst] of dirMoves) {
76
+ if (existsSync(src)) {
77
+ try {
78
+ renameSync(src, dst);
79
+ } catch {
80
+ // Cross-filesystem: use cpSync (Node 16+) then rmSync
81
+ cpSync(src, dst, { recursive: true });
82
+ rmSync(src, { recursive: true, force: true });
83
+ }
84
+ log("workspace", `Moved ${src} → ${dst}`);
85
+ }
86
+ }
87
+
88
+ // Remove old workspace/ if empty
89
+ try {
90
+ const remaining = readdirSync(oldRoot);
91
+ if (remaining.length === 0) {
92
+ rmdirSync(oldRoot);
93
+ log("workspace", "Removed empty workspace/ directory");
94
+ } else {
95
+ log("workspace", `Old workspace/ still has ${remaining.length} item(s) — not removed`);
96
+ }
97
+ } catch { /* ignore */ }
98
+
99
+ log("workspace", "Migration complete");
100
+ }
101
+
102
+ // ── Workspace init ───────────────────────────────────────────────────────────
103
+
104
+ /** Ensure workspace directories exist. */
105
+ export function initWorkspace(root: string): void {
106
+ migrateLayout();
107
+ // Ensure .talon/ tree exists
108
+ for (const dir of [dirs.root, dirs.data, dirs.workspace]) {
109
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
110
+ }
111
+ // Ensure the caller-supplied root exists too (may differ in tests)
112
+ if (!existsSync(root)) mkdirSync(root, { recursive: true });
113
+
114
+ // Ensure subdirectories exist
115
+ for (const sub of [dirs.memory, dirs.uploads, dirs.logs, dirs.stickers, dirs.prompts, dirs.traces]) {
116
+ if (!existsSync(sub)) mkdirSync(sub, { recursive: true });
117
+ }
118
+
119
+ // Seed identity.md for new workspaces
120
+ if (!existsSync(pathFiles.identity)) {
121
+ writeFileSync(pathFiles.identity, IDENTITY_SEED);
122
+ }
123
+
124
+ // Seed prompt files from the package into ~/.talon/prompts/
125
+ // Only copies files that don't already exist — user edits are preserved.
126
+ const packagePrompts = resolve(process.cwd(), "prompts");
127
+ if (existsSync(packagePrompts)) {
128
+ for (const file of readdirSync(packagePrompts)) {
129
+ if (!file.endsWith(".md")) continue;
130
+ const dst = join(dirs.prompts, file);
131
+ if (!existsSync(dst)) {
132
+ copyFileSync(join(packagePrompts, file), dst);
133
+ log("workspace", `Seeded prompt: ${file}`);
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ /** Calculate total disk usage of the workspace in bytes. */
140
+ export function getWorkspaceDiskUsage(root: string): number {
141
+ let total = 0;
142
+ function walk(dir: string): void {
143
+ try {
144
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
145
+ const full = join(dir, entry.name);
146
+ if (entry.isDirectory()) walk(full);
147
+ else if (entry.isFile()) {
148
+ try { total += statSync(full).size; } catch { /* skip */ }
149
+ }
150
+ }
151
+ } catch { /* skip */ }
152
+ }
153
+ walk(root);
154
+ return total;
155
+ }
156
+
157
+ // ── Upload cleanup ──────────────────────────────────────────────────────────
158
+
159
+ const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
160
+ const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // check every hour
161
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
162
+
163
+ /**
164
+ * Delete files in uploads/ older than maxAgeMs.
165
+ * Returns number of files deleted.
166
+ */
167
+ export function cleanupUploads(root: string, maxAgeMs = DEFAULT_MAX_AGE_MS): number {
168
+ const uploadsDir = join(root, "uploads");
169
+ if (!existsSync(uploadsDir)) return 0;
170
+
171
+ const now = Date.now();
172
+ let deleted = 0;
173
+
174
+ try {
175
+ for (const entry of readdirSync(uploadsDir, { withFileTypes: true })) {
176
+ if (!entry.isFile()) continue;
177
+ const filePath = join(uploadsDir, entry.name);
178
+ try {
179
+ const stat = statSync(filePath);
180
+ if (now - stat.mtimeMs >= maxAgeMs) {
181
+ unlinkSync(filePath);
182
+ deleted++;
183
+ }
184
+ } catch { /* skip individual file errors */ }
185
+ }
186
+ } catch { /* skip if directory unreadable */ }
187
+
188
+ if (deleted > 0) {
189
+ log("workspace", `Cleaned up ${deleted} old upload(s)`);
190
+ }
191
+ return deleted;
192
+ }
193
+
194
+ /** Start periodic upload cleanup. Call once at startup. */
195
+ export function startUploadCleanup(root: string): void {
196
+ if (cleanupTimer) return;
197
+ // Run once immediately, then every hour
198
+ cleanupUploads(root);
199
+ cleanupTimer = setInterval(() => cleanupUploads(root), CLEANUP_INTERVAL_MS);
200
+ }
201
+
202
+ /** Stop the cleanup timer. */
203
+ export function stopUploadCleanup(): void {
204
+ if (cleanupTimer) {
205
+ clearInterval(cleanupTimer);
206
+ cleanupTimer = null;
207
+ }
208
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src"
11
+ },
12
+ "include": ["src"]
13
+ }