talon-agent 5.18.2 → 5.19.1

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 (195) hide show
  1. package/README.md +2 -1
  2. package/package.json +2 -2
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/one-shot.ts +32 -2
  8. package/src/backend/claude-sdk/stream.ts +2 -2
  9. package/src/backend/codex/auth.ts +1 -1
  10. package/src/backend/codex/handler/message.ts +11 -11
  11. package/src/backend/codex/init.ts +4 -9
  12. package/src/backend/codex/mcp-config.ts +1 -2
  13. package/src/backend/codex/oauth-incompat.ts +8 -4
  14. package/src/backend/codex/one-shot.ts +1 -1
  15. package/src/backend/openai-agents/builtins.ts +55 -27
  16. package/src/backend/openai-agents/factory.ts +3 -3
  17. package/src/backend/openai-agents/handler/message.ts +3 -5
  18. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  19. package/src/backend/remote-server/chat-turn.ts +6 -6
  20. package/src/backend/remote-server/events.ts +1 -5
  21. package/src/backend/remote-server/index.ts +0 -1
  22. package/src/backend/remote-server/messages.ts +3 -7
  23. package/src/backend/remote-server/one-shot.ts +1 -3
  24. package/src/backend/remote-server/session-helpers.ts +1 -4
  25. package/src/backend/remote-server/sse-stream.ts +8 -9
  26. package/src/backend/runtime/metrics.ts +7 -13
  27. package/src/backend/runtime/sleep.ts +1 -2
  28. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  29. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  30. package/src/bootstrap.ts +9 -1
  31. package/src/cli/doctor.ts +3 -0
  32. package/src/cli/index.ts +8 -10
  33. package/src/cli/logs.ts +148 -9
  34. package/src/cli/setup.ts +9 -11
  35. package/src/cli/status.ts +29 -0
  36. package/src/core/agent-runtime/README.md +5 -19
  37. package/src/core/agent-runtime/events.ts +3 -39
  38. package/src/core/agent-runtime/model-ref.ts +0 -8
  39. package/src/core/agents/registry.ts +65 -2
  40. package/src/core/auth/expiry-monitor.ts +9 -1
  41. package/src/core/auth/login-flow.ts +9 -1
  42. package/src/core/auth/status.ts +31 -3
  43. package/src/core/background/cron/scheduler.ts +25 -5
  44. package/src/core/background/dream/index.ts +29 -10
  45. package/src/core/background/failure-backoff.ts +30 -0
  46. package/src/core/background/heartbeat/agent.ts +2 -27
  47. package/src/core/background/heartbeat/index.ts +0 -2
  48. package/src/core/background/heartbeat/scheduler.ts +21 -13
  49. package/src/core/background/heartbeat/state.ts +10 -2
  50. package/src/core/background/isolated-agent.ts +6 -2
  51. package/src/core/background/pulse/pulse.ts +9 -0
  52. package/src/core/background/triggers/exit.ts +54 -0
  53. package/src/core/background/triggers/index.ts +1 -3
  54. package/src/core/background/triggers/resume.ts +2 -4
  55. package/src/core/backup/archive/tar.ts +14 -4
  56. package/src/core/backup/plan.ts +1 -0
  57. package/src/core/backup/restore.ts +21 -13
  58. package/src/core/backup/scheduler.ts +28 -9
  59. package/src/core/backup/snapshot.ts +43 -10
  60. package/src/core/backup/store.ts +7 -19
  61. package/src/core/backup/targets.ts +69 -17
  62. package/src/core/config/index.ts +14 -0
  63. package/src/core/daemon/crash-marker.ts +141 -0
  64. package/src/core/daemon/crash.ts +9 -2
  65. package/src/core/daemon/handoff.ts +15 -0
  66. package/src/core/daemon/health-alerts.ts +297 -0
  67. package/src/core/daemon/log-reader.ts +289 -0
  68. package/src/core/doctor/index.ts +18 -2
  69. package/src/core/doctor/logs.ts +124 -0
  70. package/src/core/doctor/types.ts +1 -1
  71. package/src/core/engine/backend-controller/index.ts +1 -13
  72. package/src/core/engine/backend-router/router.ts +1 -1
  73. package/src/core/engine/dispatcher.ts +55 -2
  74. package/src/core/engine/fault-text.ts +40 -0
  75. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  76. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  77. package/src/core/engine/gateway-actions/history.ts +2 -4
  78. package/src/core/engine/gateway-actions/mesh.ts +26 -14
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  81. package/src/core/engine/gateway.ts +60 -1
  82. package/src/core/engine/turn-health.ts +222 -0
  83. package/src/core/errors.ts +2 -2
  84. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  85. package/src/core/frontend-runtime/alerts.ts +130 -0
  86. package/src/core/mcp-hub/children.ts +78 -29
  87. package/src/core/mcp-hub/index.ts +21 -18
  88. package/src/core/mcp-hub/proxy-server.ts +8 -4
  89. package/src/core/mcp-hub/talon-server.ts +5 -12
  90. package/src/core/mesh/credentials/store.ts +16 -1
  91. package/src/core/mesh/devices/registry.ts +1 -17
  92. package/src/core/mesh/devices/service.ts +49 -17
  93. package/src/core/mesh/devices/teleport.ts +14 -2
  94. package/src/core/mesh/links/node-binaries.ts +13 -6
  95. package/src/core/mesh/persist.ts +22 -10
  96. package/src/core/mesh/transfers/device-files.ts +5 -23
  97. package/src/core/mesh/transfers/transfers.ts +16 -3
  98. package/src/core/models/active-model.ts +2 -55
  99. package/src/core/plugin/actions.ts +19 -20
  100. package/src/core/plugin/builtins.ts +80 -90
  101. package/src/core/plugin/index.ts +1 -4
  102. package/src/core/plugin/loader.ts +25 -33
  103. package/src/core/plugin/mcp.ts +3 -5
  104. package/src/core/plugin/registry.ts +19 -35
  105. package/src/core/plugin/types.ts +2 -5
  106. package/src/core/prompt/assemble.ts +15 -3
  107. package/src/core/scripts/lua.ts +6 -2
  108. package/src/core/tasks/table.ts +8 -2
  109. package/src/core/tools/bridge.ts +2 -4
  110. package/src/core/tools/chat/cross-send.ts +1 -1
  111. package/src/core/tools/chat/messaging.ts +1 -1
  112. package/src/core/tools/index.ts +2 -2
  113. package/src/core/tools/mcp-env.ts +2 -59
  114. package/src/core/tools/ops/agents.ts +22 -1
  115. package/src/core/tools/schemas.ts +4 -9
  116. package/src/core/vfs/fusefs.ts +0 -5
  117. package/src/core/vfs/index.ts +9 -2
  118. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  119. package/src/core/vfs/mounts/proc.ts +17 -1
  120. package/src/core/vfs/workspace.ts +7 -3
  121. package/src/core/weaver/shuttle.ts +8 -1
  122. package/src/core/weaver/turn-log.ts +320 -0
  123. package/src/core/weaver/weaver.ts +48 -5
  124. package/src/frontend/discord/actions/index.ts +8 -1
  125. package/src/frontend/discord/diagnostics.ts +82 -6
  126. package/src/frontend/discord/handlers/index.ts +0 -2
  127. package/src/frontend/discord/middleware.ts +7 -13
  128. package/src/frontend/discord/runtime.ts +1 -3
  129. package/src/frontend/health/delivery.ts +115 -0
  130. package/src/frontend/health/outage.ts +116 -0
  131. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  132. package/src/frontend/native/bridge/server.ts +106 -21
  133. package/src/frontend/native/index.ts +1 -1
  134. package/src/frontend/native/media/media.ts +5 -1
  135. package/src/frontend/native/runtime.ts +12 -7
  136. package/src/frontend/native/surface/handlers.ts +1 -1
  137. package/src/frontend/native/surface/memory.ts +1 -1
  138. package/src/frontend/native/surface/models.ts +3 -3
  139. package/src/frontend/native/surface/settings.ts +20 -8
  140. package/src/frontend/native/turn/context.ts +6 -8
  141. package/src/frontend/native/turn/turn-meta.ts +2 -5
  142. package/src/frontend/native/turn/turn.ts +8 -10
  143. package/src/frontend/presentation/format.ts +2 -4
  144. package/src/frontend/presentation/session-status.ts +2 -6
  145. package/src/frontend/teams/actions.ts +8 -1
  146. package/src/frontend/teams/graph.ts +0 -1
  147. package/src/frontend/teams/index.ts +1 -4
  148. package/src/frontend/teams/poll.ts +40 -2
  149. package/src/frontend/teams/runtime.ts +14 -5
  150. package/src/frontend/telegram/actions/index.ts +4 -1
  151. package/src/frontend/telegram/actions/send.ts +8 -0
  152. package/src/frontend/telegram/handlers/context.ts +13 -2
  153. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  154. package/src/frontend/telegram/handlers/index.ts +0 -2
  155. package/src/frontend/telegram/index.ts +35 -9
  156. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  157. package/src/frontend/telegram/userbot.ts +100 -36
  158. package/src/frontend/terminal/builtins/session.ts +2 -2
  159. package/src/frontend/terminal/index.ts +1 -3
  160. package/src/frontend/terminal/renderer.ts +2 -18
  161. package/src/frontend/whatsapp/actions/index.ts +12 -1
  162. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  163. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  164. package/src/frontend/whatsapp/connection/health.ts +89 -0
  165. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  166. package/src/frontend/whatsapp/runtime.ts +11 -5
  167. package/src/native/blake3.ts +28 -2
  168. package/src/native/fusefs.ts +23 -5
  169. package/src/native/registry.ts +1 -1
  170. package/src/native/warden.ts +33 -5
  171. package/src/plugins/github/index.ts +0 -1
  172. package/src/plugins/mempalace/index.ts +9 -3
  173. package/src/plugins/playwright/index.ts +2 -4
  174. package/src/plugins/playwright/provision.ts +12 -4
  175. package/src/storage/chat-settings.ts +6 -1
  176. package/src/storage/cron.ts +29 -4
  177. package/src/storage/daily-log.ts +43 -47
  178. package/src/storage/db.ts +61 -33
  179. package/src/storage/history.ts +6 -1
  180. package/src/storage/journal.ts +9 -2
  181. package/src/storage/kv.ts +19 -6
  182. package/src/storage/media-index.ts +28 -6
  183. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  184. package/src/storage/repositories/sessions-repo.ts +10 -3
  185. package/src/storage/scripts.ts +24 -13
  186. package/src/storage/sessions.ts +11 -2
  187. package/src/storage/skills.ts +21 -2
  188. package/src/storage/stickers.ts +17 -3
  189. package/src/storage/triggers.ts +8 -3
  190. package/src/storage/turn-meta.ts +25 -7
  191. package/src/util/log.ts +189 -6
  192. package/src/util/logging/turn-scope.ts +85 -0
  193. package/src/util/time.ts +3 -3
  194. package/src/util/watchdog.ts +30 -0
  195. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -11,7 +11,7 @@ import {
11
11
  unlinkSync,
12
12
  } from "node:fs";
13
13
  import { resolve } from "node:path";
14
- import { log as logInfo, logError } from "../util/log.js";
14
+ import { log as logInfo, logError, logWarn } from "../util/log.js";
15
15
  import { dirs } from "../util/paths.js";
16
16
  import { toYMD } from "../util/time.js";
17
17
 
@@ -115,56 +115,52 @@ export function todayLogDate(): string {
115
115
  /** Matches YYYY-MM-DD.md filenames strictly. */
116
116
  const DAILY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
117
117
 
118
- /** Remove daily logs older than MAX_LOG_DAYS. Called on startup. */
119
- export function cleanupOldLogs(): void {
118
+ /**
119
+ * Unlink YYYY-MM-DD.md files in `dir` dated before `cutoff`. Never
120
+ * throws; a missing dir is the normal first-run case, anything else
121
+ * (unreadable dir, a file that won't unlink) is logged.
122
+ */
123
+ function pruneDatedFiles(dir: string, cutoff: string, what: string): void {
124
+ let deleted = 0;
125
+ let failed = 0;
126
+ let firstFailure = "";
120
127
  try {
121
- if (existsSync(LOGS_DIR)) {
122
- const cutoff = new Date();
123
- cutoff.setDate(cutoff.getDate() - MAX_LOG_DAYS);
124
- const cutoffStr = localDateKey(cutoff);
125
-
126
- let deleted = 0;
127
- for (const file of readdirSync(LOGS_DIR)) {
128
- if (DAILY_FILE_RE.test(file) && file < cutoffStr) {
129
- try {
130
- unlinkSync(resolve(LOGS_DIR, file));
131
- deleted++;
132
- } catch {
133
- /* skip */
134
- }
135
- }
136
- }
137
- if (deleted > 0) {
138
- logInfo("workspace", `Cleaned up ${deleted} old daily log(s)`);
128
+ if (!existsSync(dir)) return;
129
+ for (const file of readdirSync(dir)) {
130
+ if (!DAILY_FILE_RE.test(file) || file >= cutoff) continue;
131
+ try {
132
+ unlinkSync(resolve(dir, file));
133
+ deleted++;
134
+ } catch (err) {
135
+ if (failed++ === 0) firstFailure = `${file}: ${errText(err)}`;
139
136
  }
140
137
  }
141
- } catch {
142
- /* skip */
138
+ } catch (err) {
139
+ logWarn("workspace", `${what} cleanup failed dir=${dir}: ${errText(err)}`);
140
+ }
141
+ if (deleted > 0) {
142
+ logInfo("workspace", `Cleaned up ${deleted} old ${what}(s)`);
143
+ }
144
+ if (failed > 0) {
145
+ logWarn(
146
+ "workspace",
147
+ `Could not remove ${failed} old ${what}(s) dir=${dir}; first=${firstFailure}`,
148
+ );
143
149
  }
150
+ }
151
+
152
+ function errText(err: unknown): string {
153
+ return err instanceof Error ? err.message : String(err);
154
+ }
155
+
156
+ /** Remove daily logs older than MAX_LOG_DAYS. Called on startup. */
157
+ export function cleanupOldLogs(): void {
158
+ const cutoff = new Date();
159
+ cutoff.setDate(cutoff.getDate() - MAX_LOG_DAYS);
160
+ pruneDatedFiles(LOGS_DIR, localDateKey(cutoff), "daily log");
144
161
 
145
162
  // Clean up old daily memory files (independent of logs dir)
146
- try {
147
- const dailyMemDir = dirs.dailyMemory;
148
- if (!existsSync(dailyMemDir)) return;
149
- const cutoffDate = new Date();
150
- cutoffDate.setDate(cutoffDate.getDate() - MAX_LOG_DAYS);
151
- const cutoffMem = toYMD(cutoffDate);
152
-
153
- let deletedMem = 0;
154
- for (const file of readdirSync(dailyMemDir)) {
155
- if (DAILY_FILE_RE.test(file) && file < cutoffMem) {
156
- try {
157
- unlinkSync(resolve(dailyMemDir, file));
158
- deletedMem++;
159
- } catch {
160
- /* skip */
161
- }
162
- }
163
- }
164
- if (deletedMem > 0) {
165
- logInfo("workspace", `Cleaned up ${deletedMem} old daily memory file(s)`);
166
- }
167
- } catch {
168
- /* skip */
169
- }
163
+ const cutoffDate = new Date();
164
+ cutoffDate.setDate(cutoffDate.getDate() - MAX_LOG_DAYS);
165
+ pruneDatedFiles(dirs.dailyMemory, toYMD(cutoffDate), "daily memory file");
170
166
  }
package/src/storage/db.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  import { mkdirSync } from "node:fs";
28
28
  import { dirname } from "node:path";
29
29
  import { files } from "../util/paths.js";
30
- import { log, logError } from "../util/log.js";
30
+ import { log, logError, logWarn } from "../util/log.js";
31
31
  import { SCHEMA, dbSql } from "./sql/statements.generated.js";
32
32
 
33
33
  /**
@@ -66,18 +66,20 @@ const ReadOnlyDatabase = Database as unknown as new (
66
66
 
67
67
  let db: SqlDatabase | null = null;
68
68
 
69
+ /** How long a write waits on another connection's lock before SQLITE_BUSY. */
70
+ const BUSY_TIMEOUT_MS = 5_000;
71
+
69
72
  /**
70
73
  * Apply the complete schema. Every statement is IF NOT EXISTS, so this
71
74
  * is a no-op on an up-to-date database and creates exactly what's
72
75
  * missing on a fresh or older one.
73
76
  *
74
77
  * Column reconciliation runs first: `ALTER TABLE … ADD COLUMN` has no
75
- * IF NOT EXISTS form, so columns added to already-shipped tables
76
- * (media_index.content_hash, sessions.metrics,
77
- * history_messages.attachments, sessions.last_turn_ended_at) are ensured by attempting the ALTER and
78
- * swallowing the two expected failures — "duplicate column name"
79
- * (column already there) and "no such table" (fresh database; the
80
- * CREATE TABLE in schema.sql includes the column).
78
+ * IF NOT EXISTS form, so columns added to already-shipped tables are
79
+ * ensured by attempting the ALTER and swallowing the two expected
80
+ * failures — "duplicate column name" (column already there) and "no
81
+ * such table" (fresh database; the CREATE TABLE in schema.sql includes
82
+ * the column).
81
83
  */
82
84
  function ensureSchema(database: SqlDatabase): void {
83
85
  const row = database
@@ -85,30 +87,27 @@ function ensureSchema(database: SqlDatabase): void {
85
87
  "SELECT COUNT(*) AS tables FROM sqlite_master WHERE type = 'table'",
86
88
  )
87
89
  .get() as { tables: number };
88
- try {
89
- database.exec(dbSql.addMediaContentHashColumn);
90
- } catch {
91
- /* duplicate column or no such table — both mean nothing to do */
92
- }
93
- try {
94
- database.exec(dbSql.addHistorySenderHandleColumn);
95
- } catch {
96
- /* duplicate column or no such table — both mean nothing to do */
97
- }
98
- try {
99
- database.exec(dbSql.addSessionsMetricsColumn);
100
- } catch {
101
- /* duplicate column or no such table — both mean nothing to do */
102
- }
103
- try {
104
- database.exec(dbSql.addHistoryAttachmentsColumn);
105
- } catch {
106
- /* duplicate column or no such table — both mean nothing to do */
107
- }
108
- try {
109
- database.exec(dbSql.addSessionsLastTurnEndedAtColumn);
110
- } catch {
111
- /* duplicate column or no such table — both mean nothing to do */
90
+ for (const addColumn of [
91
+ dbSql.addMediaContentHashColumn,
92
+ dbSql.addHistorySenderHandleColumn,
93
+ dbSql.addSessionsMetricsColumn,
94
+ dbSql.addHistoryAttachmentsColumn,
95
+ dbSql.addSessionsLastTurnEndedAtColumn,
96
+ ]) {
97
+ try {
98
+ database.exec(addColumn);
99
+ } catch (err) {
100
+ // Duplicate column or no such table both mean nothing to do. Any
101
+ // other failure (read-only file, full disk) is a real fault the
102
+ // schema step below will likely trip over too — say which ALTER.
103
+ const msg = err instanceof Error ? err.message : String(err);
104
+ if (!/duplicate column name|no such table/i.test(msg)) {
105
+ logWarn(
106
+ "db",
107
+ `schema column reconcile failed: ${msg}${dbErrorFields(err)} sql=${JSON.stringify(addColumn.slice(0, 60))}`,
108
+ );
109
+ }
110
+ }
112
111
  }
113
112
  database.exec("BEGIN");
114
113
  try {
@@ -121,6 +120,24 @@ function ensureSchema(database: SqlDatabase): void {
121
120
  if (row.tables === 0) log("db", "Initialized database schema");
122
121
  }
123
122
 
123
+ /**
124
+ * The SQLite result code behind a driver error, as ` key=value` log
125
+ * fields (leading space; empty when there is none). node:sqlite keeps
126
+ * it out of the message — "database or disk is full" arrives as
127
+ * `errcode: 13` — so a log line built from `err.message` alone can't
128
+ * be grepped for SQLITE_FULL (13), SQLITE_BUSY (5) or SQLITE_READONLY (8).
129
+ */
130
+ export function dbErrorFields(err: unknown): string {
131
+ if (!err || typeof err !== "object") return "";
132
+ const e = err as { code?: unknown; errcode?: unknown; errno?: unknown };
133
+ let out = "";
134
+ if (typeof e.code === "string") out += ` code=${e.code}`;
135
+ // node:sqlite names it errcode, bun:sqlite errno.
136
+ const rc = typeof e.errcode === "number" ? e.errcode : e.errno;
137
+ if (typeof rc === "number") out += ` errcode=${rc}`;
138
+ return out;
139
+ }
140
+
124
141
  function defaultPath(): string {
125
142
  // Test isolation: the vitest setup file points every worker at a
126
143
  // throwaway database so suites never touch ~/.talon/data/talon.db.
@@ -137,6 +154,12 @@ export function getDatabase(path: string = defaultPath()): SqlDatabase {
137
154
  mkdirSync(dirname(path), { recursive: true });
138
155
  const database = new Database(path);
139
156
  try {
157
+ // Other processes write this file too (CLI commands, a respawned
158
+ // successor overlapping its predecessor). Both drivers default to a
159
+ // zero busy timeout, i.e. "database is locked" the instant a write
160
+ // meets theirs — wait for the lock instead. First, so the pragmas
161
+ // and schema setup below get it too.
162
+ database.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
140
163
  // WAL: readers don't block the writer, and crash recovery is
141
164
  // journal-based instead of "hope the rename was atomic".
142
165
  database.exec("PRAGMA journal_mode = WAL");
@@ -216,8 +239,13 @@ export function flushDatabase(): void {
216
239
  if (!db) return;
217
240
  try {
218
241
  db.exec(dbSql.walCheckpoint);
219
- } catch {
220
- /* shutting down — best effort */
242
+ } catch (err) {
243
+ // Shutting down — best effort, but a checkpoint that fails here
244
+ // (disk full, I/O error) is the last word on why the WAL grew.
245
+ logWarn(
246
+ "db",
247
+ `WAL checkpoint failed: ${err instanceof Error ? err.message : String(err)}${dbErrorFields(err)}`,
248
+ );
221
249
  }
222
250
  }
223
251
 
@@ -24,6 +24,7 @@ import { recordError } from "../util/watchdog.js";
24
24
  import { files } from "../util/paths.js";
25
25
  import { formatSmartTimestamp, formatRelativeAge } from "../util/time.js";
26
26
  import { importLegacyJson } from "./legacy-import.js";
27
+ import { dbErrorFields } from "./db.js";
27
28
  import * as repo from "./repositories/history-repo.js";
28
29
 
29
30
  export type {
@@ -77,7 +78,11 @@ export function pushMessage(chatId: string, msg: HistoryMessage): void {
77
78
  try {
78
79
  repo.insert(chatId, msg);
79
80
  } catch (err) {
80
- logError("history", "Failed to persist message", err);
81
+ logError(
82
+ "history",
83
+ `Failed to persist message chat=${chatId} msg=${msg.msgId}${dbErrorFields(err)}`,
84
+ err,
85
+ );
81
86
  recordError(
82
87
  `History write failed: ${err instanceof Error ? err.message : err}`,
83
88
  );
@@ -15,7 +15,7 @@
15
15
  * PRUNE_EVERY appends) rather than on a timer.
16
16
  */
17
17
 
18
- import { logError } from "../util/log.js";
18
+ import { logError, logWarn } from "../util/log.js";
19
19
  import * as repo from "./repositories/journal-repo.js";
20
20
 
21
21
  /**
@@ -82,6 +82,7 @@ export function readJournal<E extends JournalRecord = JournalRecord>(
82
82
  ? repo.recentByType(options.type, limit)
83
83
  : repo.recent(limit);
84
84
  const entries: JournalEntry<E>[] = [];
85
+ const corrupt: number[] = [];
85
86
  for (const row of rows) {
86
87
  try {
87
88
  entries.push({
@@ -90,9 +91,15 @@ export function readJournal<E extends JournalRecord = JournalRecord>(
90
91
  event: JSON.parse(row.payload) as E,
91
92
  });
92
93
  } catch {
93
- // skip the corrupt row
94
+ corrupt.push(row.seq); // skip the corrupt row
94
95
  }
95
96
  }
97
+ if (corrupt.length > 0) {
98
+ logWarn(
99
+ "journal",
100
+ `Skipped ${corrupt.length} corrupt journal row(s) seq=${corrupt.slice(0, 5).join(",")}`,
101
+ );
102
+ }
96
103
  return entries;
97
104
  }
98
105
 
package/src/storage/kv.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import * as repo from "./repositories/kv-repo.js";
17
+ import { dbErrorFields } from "./db.js";
17
18
  import { logError } from "../util/log.js";
18
19
 
19
20
  export function kvGet<T>(key: string): T | undefined {
@@ -21,7 +22,7 @@ export function kvGet<T>(key: string): T | undefined {
21
22
  try {
22
23
  raw = repo.get(key);
23
24
  } catch (err) {
24
- logError("kv", `Failed to read ${key}`, err);
25
+ logError("kv", `Failed to read ${key}${dbErrorFields(err)}`, err);
25
26
  return undefined;
26
27
  }
27
28
  if (raw === undefined) return undefined;
@@ -33,12 +34,24 @@ export function kvGet<T>(key: string): T | undefined {
33
34
  }
34
35
  }
35
36
 
36
- /** Persist a JSON-serialisable value. Storage failure logs, never throws. */
37
- export function kvSet(key: string, value: unknown): void {
37
+ /**
38
+ * Persist a JSON-serialisable value. Storage failure logs (with the
39
+ * SQLite result code, so a full disk reads as errcode=13) and never
40
+ * throws; the return says whether the write landed.
41
+ */
42
+ export function kvSet(key: string, value: unknown): boolean {
43
+ let json: string | undefined;
38
44
  try {
39
- repo.set(key, JSON.stringify(value), Date.now());
45
+ json = JSON.stringify(value);
46
+ repo.set(key, json, Date.now());
47
+ return true;
40
48
  } catch (err) {
41
- logError("kv", `Failed to write ${key}`, err);
49
+ logError(
50
+ "kv",
51
+ `Failed to write ${key} bytes=${json?.length ?? "?"}${dbErrorFields(err)}`,
52
+ err,
53
+ );
54
+ return false;
42
55
  }
43
56
  }
44
57
 
@@ -46,7 +59,7 @@ export function kvDelete(key: string): boolean {
46
59
  try {
47
60
  return repo.remove(key);
48
61
  } catch (err) {
49
- logError("kv", `Failed to delete ${key}`, err);
62
+ logError("kv", `Failed to delete ${key}${dbErrorFields(err)}`, err);
50
63
  return false;
51
64
  }
52
65
  }
@@ -17,11 +17,12 @@
17
17
 
18
18
  import { existsSync, unlinkSync } from "node:fs";
19
19
  import { blake3HexFile } from "../native/blake3.js";
20
- import { log, logError } from "../util/log.js";
20
+ import { log, logError, logWarn } from "../util/log.js";
21
21
  import { recordError } from "../util/watchdog.js";
22
22
  import { files } from "../util/paths.js";
23
23
  import { importLegacyJson } from "./legacy-import.js";
24
24
  import { setMessageFilePath } from "./history.js";
25
+ import { dbErrorFields } from "./db.js";
25
26
  import * as repo from "./repositories/media-index-repo.js";
26
27
 
27
28
  export type { MediaEntry } from "./repositories/media-index-repo.js";
@@ -75,7 +76,11 @@ export function addMedia(entry: Omit<MediaEntry, "id">): void {
75
76
  try {
76
77
  repo.upsert(entry);
77
78
  } catch (err) {
78
- logError("media", "Media index save failed", err);
79
+ logError(
80
+ "media",
81
+ `Media index save failed chat=${entry.chatId} msg=${entry.msgId} path=${entry.filePath}${dbErrorFields(err)}`,
82
+ err,
83
+ );
79
84
  recordError(
80
85
  `Media index write failed: ${err instanceof Error ? err.message : err}`,
81
86
  );
@@ -116,8 +121,13 @@ async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<void> {
116
121
  "media",
117
122
  `Deduped ${entry.filePath} -> ${canonical.filePath} (blake3 ${hash.slice(0, 12)}…)`,
118
123
  );
119
- } catch {
120
- /* dedupe is best-effort; the entry already points at the canonical copy */
124
+ } catch (err) {
125
+ // Dedupe is best-effort — the entry already points at the canonical
126
+ // copy — but the duplicate now sits on disk unreferenced.
127
+ logWarn(
128
+ "media",
129
+ `Dedupe unlink failed path=${entry.filePath}: ${err instanceof Error ? err.message : String(err)}`,
130
+ );
121
131
  }
122
132
  }
123
133
  }
@@ -161,17 +171,29 @@ function purgeExpired(): void {
161
171
  const removed = repo.deleteOlderThan(cutoff);
162
172
  // Rows first, files second: content dedupe means several entries can
163
173
  // share one file, so only unlink paths no surviving row references.
174
+ let unlinkFailures = 0;
175
+ let firstFailure = "";
164
176
  for (const path of new Set(expired.map((e) => e.filePath))) {
165
177
  if (repo.countByFilePath(path) > 0) continue;
166
178
  try {
167
179
  if (existsSync(path)) unlinkSync(path);
168
- } catch {
169
- /* skip */
180
+ } catch (err) {
181
+ // Skip it, but count: files left behind are disk that the purge
182
+ // thinks it reclaimed.
183
+ if (unlinkFailures++ === 0) {
184
+ firstFailure = `${path}: ${err instanceof Error ? err.message : String(err)}`;
185
+ }
170
186
  }
171
187
  }
172
188
  if (removed > 0) {
173
189
  log("media", `Purged ${removed} expired media entries`);
174
190
  }
191
+ if (unlinkFailures > 0) {
192
+ logWarn(
193
+ "media",
194
+ `Media purge left ${unlinkFailures} expired file(s) on disk; first=${firstFailure}`,
195
+ );
196
+ }
175
197
  } catch (err) {
176
198
  logError("media", "Media index purge failed", err);
177
199
  }
@@ -11,6 +11,7 @@
11
11
  * queried independently in SQL.
12
12
  */
13
13
 
14
+ import { logWarn } from "../../util/log.js";
14
15
  import { getDatabase, inTransaction } from "../db.js";
15
16
  import { chatSettingsSql } from "../sql/statements.generated.js";
16
17
  import type { ReasoningEffortLevel } from "../../types/effort.js";
@@ -102,8 +103,13 @@ export function all(): Array<{ chatId: string; settings: ChatSettings }> {
102
103
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
103
104
  result.push({ chatId: row.chat_id, settings: parsed });
104
105
  }
105
- } catch {
106
- /* skip unparseable row */
106
+ } catch (err) {
107
+ // Skip the unparseable row; the chat silently falls back to
108
+ // defaults otherwise.
109
+ logWarn(
110
+ "settings",
111
+ `Skipped corrupt chat settings chat=${row.chat_id}: ${err instanceof Error ? err.message : String(err)}`,
112
+ );
107
113
  }
108
114
  }
109
115
  return result;
@@ -12,6 +12,7 @@
12
12
  * column default can hold.
13
13
  */
14
14
 
15
+ import { logWarn } from "../../util/log.js";
15
16
  import { getDatabase, inTransaction } from "../db.js";
16
17
  import { sessionsSql } from "../sql/statements.generated.js";
17
18
  import {
@@ -46,13 +47,19 @@ type Row = {
46
47
  metrics: string | null;
47
48
  };
48
49
 
49
- function parseMetrics(raw: string | null): SessionMetrics {
50
+ function parseMetrics(raw: string | null, chatId: string): SessionMetrics {
50
51
  if (!raw) return emptyMetrics();
51
52
  try {
52
53
  // Normalise at the load boundary: backfills partial blobs and restores
53
54
  // the Infinity minMs sentinel that JSON round-trips as null.
54
55
  return normaliseMetrics(JSON.parse(raw));
55
- } catch {
56
+ } catch (err) {
57
+ // Degrade to empty — and say so, since the next upsert overwrites
58
+ // the corrupt blob and the chat's lifetime counters restart at zero.
59
+ logWarn(
60
+ "sessions",
61
+ `Corrupt session metrics chat=${chatId} bytes=${raw.length} — resetting: ${err instanceof Error ? err.message : String(err)}`,
62
+ );
56
63
  return emptyMetrics();
57
64
  }
58
65
  }
@@ -78,7 +85,7 @@ function rowToSession(row: Row): SessionState {
78
85
  // NULL = no timed turn yet — the domain sentinel is Infinity.
79
86
  fastestResponseMs: row.fastest_response_ms ?? Infinity,
80
87
  },
81
- metrics: parseMetrics(row.metrics),
88
+ metrics: parseMetrics(row.metrics, row.chat_id),
82
89
  lastBotMessageId: row.last_bot_message_id ?? undefined,
83
90
  sessionName: row.session_name ?? undefined,
84
91
  lastModel: row.last_model ?? undefined,
@@ -17,8 +17,10 @@
17
17
  */
18
18
 
19
19
  import { randomUUID } from "node:crypto";
20
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
20
+ import { mkdirSync, rmSync } from "node:fs";
21
21
  import { dirname, resolve } from "node:path";
22
+ import writeFileAtomic from "write-file-atomic";
23
+ import { logWarn } from "../util/log.js";
22
24
  import { dirs } from "../util/paths.js";
23
25
  import * as repo from "./repositories/scripts-repo.js";
24
26
 
@@ -93,8 +95,9 @@ function writeScriptFile(
93
95
  ): string {
94
96
  const path = scriptFilePath(name, lang);
95
97
  mkdirSync(dirname(path), { recursive: true });
96
- // 0o700: only the user running Talon should be able to read/exec scripts
97
- writeFileSync(path, body, { encoding: "utf-8", mode: 0o700 });
98
+ // 0o700: only the user running Talon should be able to read/exec scripts.
99
+ // Atomic: a failed replace (ENOSPC) must keep the previous body.
100
+ writeFileAtomic.sync(path, body, { encoding: "utf-8", mode: 0o700 });
98
101
  return path;
99
102
  }
100
103
 
@@ -117,11 +120,7 @@ export function saveScript(input: {
117
120
  }): Script {
118
121
  const existing = repo.getByName(input.name);
119
122
  if (existing && existing.language !== input.language) {
120
- try {
121
- rmSync(existing.scriptPath, { force: true });
122
- } catch {
123
- /* best effort */
124
- }
123
+ removeScriptFile(existing.scriptPath, input.name);
125
124
  }
126
125
  const scriptPath = writeScriptFile(input.name, input.language, input.script);
127
126
  const now = Date.now();
@@ -153,15 +152,27 @@ export function recordScriptUse(name: string): void {
153
152
  }
154
153
 
155
154
  /** Delete a script and best-effort clean up its on-disk file. */
155
+ /**
156
+ * Best-effort unlink. `force` already absorbs a missing file, so any
157
+ * throw here is a real fault (permissions, a directory in the way) that
158
+ * leaves an orphan script on disk.
159
+ */
160
+ function removeScriptFile(path: string, name: string): void {
161
+ try {
162
+ rmSync(path, { force: true });
163
+ } catch (err) {
164
+ logWarn(
165
+ "scripts",
166
+ `Could not remove script file name=${name} path=${path}: ${err instanceof Error ? err.message : String(err)}`,
167
+ );
168
+ }
169
+ }
170
+
156
171
  export function deleteScript(name: string): boolean {
157
172
  const script = repo.getByName(name);
158
173
  if (!script) return false;
159
174
  repo.removeByName(name);
160
- try {
161
- rmSync(script.scriptPath, { force: true });
162
- } catch {
163
- /* best effort */
164
- }
175
+ removeScriptFile(script.scriptPath, name);
165
176
  return true;
166
177
  }
167
178
 
@@ -22,6 +22,7 @@ import { log, logError } from "../util/log.js";
22
22
  import { recordError } from "../util/watchdog.js";
23
23
  import { files } from "../util/paths.js";
24
24
  import { importLegacyJson } from "./legacy-import.js";
25
+ import { dbErrorFields } from "./db.js";
25
26
  import * as repo from "./repositories/sessions-repo.js";
26
27
 
27
28
  export type {
@@ -204,7 +205,11 @@ function persist(chatId: string, session: SessionState): void {
204
205
  try {
205
206
  repo.upsert(chatId, session);
206
207
  } catch (err) {
207
- logError("sessions", "Failed to persist sessions", err);
208
+ logError(
209
+ "sessions",
210
+ `Failed to persist sessions chat=${chatId}${dbErrorFields(err)}`,
211
+ err,
212
+ );
208
213
  recordError(
209
214
  `Session save failed: ${err instanceof Error ? err.message : err}`,
210
215
  );
@@ -518,7 +523,11 @@ function removeSessionRow(chatId: string): void {
518
523
  try {
519
524
  repo.remove(chatId);
520
525
  } catch (err) {
521
- logError("sessions", "Failed to delete session row", err);
526
+ logError(
527
+ "sessions",
528
+ `Failed to delete session row chat=${chatId}${dbErrorFields(err)}`,
529
+ err,
530
+ );
522
531
  recordError(
523
532
  `Session delete failed: ${err instanceof Error ? err.message : err}`,
524
533
  );
@@ -26,7 +26,9 @@ import {
26
26
  writeFileSync,
27
27
  } from "node:fs";
28
28
  import { basename, resolve } from "node:path";
29
+ import writeFileAtomic from "write-file-atomic";
29
30
  import { parseDocument, stringify } from "yaml";
31
+ import { logWarn } from "../util/log.js";
30
32
  import { dirs } from "../util/paths.js";
31
33
 
32
34
  export type Skill = {
@@ -123,6 +125,13 @@ function serializeSkill(input: {
123
125
  return ["---", yaml, "---", "", input.body.trimEnd(), ""].join("\n");
124
126
  }
125
127
 
128
+ /**
129
+ * Frontmatter errors already reported, keyed by path + message: skills
130
+ * are re-read for every prompt, so a broken SKILL.md warns once, not
131
+ * once per turn.
132
+ */
133
+ const reportedFrontmatterErrors = new Set<string>();
134
+
126
135
  function parseSkill(
127
136
  path: string,
128
137
  raw: string,
@@ -161,7 +170,15 @@ function parseSkill(
161
170
  if (value && typeof value === "object") {
162
171
  parsed = value as Record<string, unknown>;
163
172
  }
164
- } catch {
173
+ } catch (err) {
174
+ // The skill still loads (name falls back to its directory, no
175
+ // description) — which is exactly why the reason must be visible.
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ const key = `${path}\0${msg}`;
178
+ if (!reportedFrontmatterErrors.has(key)) {
179
+ reportedFrontmatterErrors.add(key);
180
+ logWarn("skills", `Unparseable frontmatter in ${path}: ${msg}`);
181
+ }
165
182
  parsed = {};
166
183
  }
167
184
 
@@ -216,7 +233,9 @@ export function saveSkill(input: {
216
233
  mkdirSync(dir, { recursive: true });
217
234
  const path = skillFilePath(input.name);
218
235
  const content = serializeSkill(input);
219
- writeFileSync(path, content, { encoding: "utf-8", mode: 0o600 });
236
+ // Atomic: a failed replace (ENOSPC) must keep the previous SKILL.md,
237
+ // not leave a truncated one.
238
+ writeFileAtomic.sync(path, content, { encoding: "utf-8", mode: 0o600 });
220
239
  return readSkill(input.name)!;
221
240
  }
222
241