talon-agent 1.39.0 → 1.40.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.
package/README.md CHANGED
@@ -184,7 +184,22 @@ GitHub API access via the official GitHub MCP server. Gives the agent access to
184
184
 
185
185
  The token is optional --- defaults to the output of `gh auth token` if the GitHub CLI is authenticated.
186
186
 
187
- ### MemPalace
187
+ ### Long-term Memory
188
+
189
+ Talon supports two long-term memory backends, selected via the unified `memory` section:
190
+
191
+ ```json
192
+ {
193
+ "memory": {
194
+ "enabled": true,
195
+ "backend": "mempalace"
196
+ }
197
+ }
198
+ ```
199
+
200
+ Set `"backend"` to `"mempalace"` (local, vector search + knowledge graph) or `"mem0"` ([mem0](https://github.com/mem0ai/mem0) hosted platform or self-hosted server). Backend-specific settings go in a matching `memory.mempalace` / `memory.mem0` sub-object. The legacy top-level `mempalace` section is still honored when `memory` is absent.
201
+
202
+ #### MemPalace backend
188
203
 
189
204
  Structured long-term memory with vector search. The agent can store, search, and retrieve memories semantically. Integrates with Dream mode for automatic memory consolidation and personal diary entries.
190
205
 
@@ -199,16 +214,40 @@ python -m venv ~/.talon/mempalace-venv
199
214
 
200
215
  ```json
201
216
  {
202
- "mempalace": {
217
+ "memory": {
203
218
  "enabled": true,
204
- "palacePath": "~/.talon/workspace/palace",
205
- "pythonPath": "~/.talon/mempalace-venv/bin/python"
219
+ "backend": "mempalace",
220
+ "mempalace": {
221
+ "palacePath": "~/.talon/workspace/palace",
222
+ "pythonPath": "~/.talon/mempalace-venv/bin/python"
223
+ }
206
224
  }
207
225
  }
208
226
  ```
209
227
 
210
228
  Both paths are optional --- defaults to `~/.talon/workspace/palace/` and the venv Python respectively.
211
229
 
230
+ #### mem0 backend
231
+
232
+ Long-term memory via [mem0](https://mem0.ai) --- mem0 extracts durable facts from what the agent stores and retrieves them by semantic search. Works against the hosted platform (API key) or a self-hosted mem0 server.
233
+
234
+ **Requirements:** None --- the `mem0ai` SDK is bundled with Talon.
235
+
236
+ ```json
237
+ {
238
+ "memory": {
239
+ "enabled": true,
240
+ "backend": "mem0",
241
+ "mem0": {
242
+ "apiKey": "m0-...",
243
+ "userId": "talon"
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ `apiKey` defaults to the `MEM0_API_KEY` env var. For a self-hosted server set `"host"` instead --- the key is then optional. `userId` is the entity id memories are filed under (default `"talon"`).
250
+
212
251
  ### Playwright
213
252
 
214
253
  Headless browser automation via the Playwright MCP server. The agent can browse websites, take screenshots, generate PDFs, fill forms, and scrape content.
@@ -320,7 +359,8 @@ Config file: `~/.talon/config.json`
320
359
  | `allowedUsers` | --- | Whitelist of Telegram user IDs |
321
360
  | `apiId` / `apiHash` | --- | Telegram API credentials for full message history |
322
361
  | `github` | --- | GitHub plugin config (see above) |
323
- | `mempalace` | --- | MemPalace plugin config (see above) |
362
+ | `memory` | --- | Long-term memory backend selection: `mempalace` or `mem0` (see above) |
363
+ | `mempalace` | --- | Legacy MemPalace plugin config (prefer `memory`) |
324
364
  | `playwright` | --- | Playwright plugin config (see above) |
325
365
 
326
366
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.39.0",
3
+ "version": "1.40.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -113,6 +113,7 @@
113
113
  "grammy": "^1.42.0",
114
114
  "liquidjs": "^10.27.0",
115
115
  "marked": "^18.0.0",
116
+ "mem0ai": "^3.0.13",
116
117
  "p-retry": "^8.0.0",
117
118
  "picocolors": "^1.1.1",
118
119
  "pino": "^10.3.1",
@@ -0,0 +1,18 @@
1
+ # mem0 — Long-term Memory
2
+
3
+ You have access to mem0 long-term memory via MCP tools. mem0 extracts durable facts from what you store and retrieves them by semantic search. All memories are filed under the entity id `{{userId}}`.
4
+
5
+ ### Protocol — FOLLOW EVERY SESSION
6
+
7
+ 1. **BEFORE RESPONDING** about any person, project, or past event: call `mem0_search_memory` FIRST. Never guess — verify from memory.
8
+ 2. **IF UNSURE** about a fact (name, age, relationship, preference): search memory. Wrong is worse than slow.
9
+ 3. **WHEN FACTS CHANGE**: store the new fact with `mem0_add_memory` (mem0 supersedes contradicted memories itself); delete plainly wrong entries with `mem0_delete_memory`.
10
+ 4. **AFTER LEARNING** something important: store it with `mem0_add_memory`. Pass natural conversational text — mem0 extracts the durable facts.
11
+
12
+ ### Tools
13
+
14
+ - `mem0_search_memory` — Semantic search. Short keyword queries, not full sentences. Optional `limit`, `threshold`.
15
+ - `mem0_add_memory` — Store information. `role` marks who it came from; optional `metadata` tags.
16
+ - `mem0_list_memories` — Paginated browse for inventory/cleanup, not search.
17
+ - `mem0_get_memory` — Fetch one memory by id with full content and metadata.
18
+ - `mem0_delete_memory` — Remove a memory by id when it is wrong or stale.
package/src/bootstrap.ts CHANGED
@@ -135,11 +135,12 @@ export async function bootstrap(
135
135
  ): Promise<BootstrapResult> {
136
136
  const config = loadConfig();
137
137
 
138
- // Load plugins (external tool packages + built-in GitHub, MemPalace, Playwright)
138
+ // Load plugins (external tool packages + built-in GitHub, MemPalace, mem0, Playwright)
139
139
  const hasPlugins =
140
140
  config.plugins.length > 0 ||
141
141
  config.github?.enabled === true ||
142
142
  config.mempalace?.enabled === true ||
143
+ config.mem0?.enabled === true ||
143
144
  config.playwright?.enabled === true;
144
145
  if (hasPlugins) {
145
146
  const { loadPlugins, loadBuiltinPlugins, getPluginPromptAdditions } =
@@ -153,7 +154,7 @@ export async function bootstrap(
153
154
  await loadPlugins(config.plugins, frontends);
154
155
  }
155
156
 
156
- // Built-in plugins (GitHub, MemPalace, Playwright) — shared with hot-reload
157
+ // Built-in plugins (GitHub, MemPalace, mem0, Playwright) — shared with hot-reload
157
158
  await loadBuiltinPlugins(config);
158
159
 
159
160
  rebuildSystemPrompt(config, getPluginPromptAdditions());
@@ -22,6 +22,65 @@ import { HeartbeatTimeoutError, runHeartbeatAgent } from "./agent.js";
22
22
  const STARTUP_DELAY_MS = 5 * 60 * 1000; // 5-minute delay before first run
23
23
  const DUE_CHECK_INTERVAL_MS = 60 * 1000;
24
24
 
25
+ // Failure backoff. A failed run does NOT advance last_run, so without a
26
+ // backoff the every-minute due check re-fires the heartbeat 60×/hour against
27
+ // the same error (observed live: a session-limit night produced hundreds of
28
+ // identical failures a minute apart). Generic failures back off
29
+ // exponentially; session/rate-limit errors that state their own reset time
30
+ // wait for it instead of guessing.
31
+ const FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
32
+ const FAILURE_BACKOFF_MAX_MS = 60 * 60 * 1000;
33
+ /** Safety margin added past a parsed limit-reset time (clock skew, rollout). */
34
+ const LIMIT_RESET_BUFFER_MS = 2 * 60 * 1000;
35
+
36
+ /**
37
+ * Parse the reset wall-clock time out of a session/rate-limit error message,
38
+ * e.g. "You've hit your session limit · resets 12:20am (UTC)" or
39
+ * "… resets 3pm (UTC)". Returns the epoch ms of the NEXT occurrence of that
40
+ * UTC time after `now`, or null when the message doesn't carry one.
41
+ */
42
+ export function parseSessionLimitResetMs(
43
+ message: string,
44
+ now: number,
45
+ ): number | null {
46
+ const m = /resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i.exec(
47
+ message,
48
+ );
49
+ if (!m) return null;
50
+ const rawHour = Number(m[1]);
51
+ const minute = m[2] ? Number(m[2]) : 0;
52
+ if (rawHour < 1 || rawHour > 12 || minute > 59) return null;
53
+ let hour = rawHour % 12;
54
+ if (m[3].toLowerCase() === "pm") hour += 12;
55
+ const d = new Date(now);
56
+ d.setUTCHours(hour, minute, 0, 0);
57
+ let t = d.getTime();
58
+ if (t <= now) t += 24 * 60 * 60 * 1000;
59
+ return t;
60
+ }
61
+
62
+ /**
63
+ * When to next allow an auto heartbeat after the Nth consecutive failure
64
+ * (1-based). Limit errors with a stated reset time wait until then (+buffer);
65
+ * everything else doubles from 5min up to a 60min cap.
66
+ */
67
+ export function failureBackoffUntil(
68
+ err: unknown,
69
+ consecutiveFailures: number,
70
+ now: number,
71
+ ): number {
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ if (/session limit|rate limit/i.test(message)) {
74
+ const reset = parseSessionLimitResetMs(message, now);
75
+ if (reset !== null) return reset + LIMIT_RESET_BUFFER_MS;
76
+ }
77
+ const exp = Math.min(
78
+ FAILURE_BACKOFF_BASE_MS * 2 ** Math.max(0, consecutiveFailures - 1),
79
+ FAILURE_BACKOFF_MAX_MS,
80
+ );
81
+ return now + exp;
82
+ }
83
+
25
84
  export function initHeartbeat(cfg: HeartbeatConfig): void {
26
85
  hb.config = cfg;
27
86
  }
@@ -69,6 +128,9 @@ export function startHeartbeatTimer(intervalMinutes: number): void {
69
128
  */
70
129
  function runIfDue(intervalMs: number, startup: boolean): void {
71
130
  if (hb.running) return;
131
+ // Inside a failure backoff window — stay quiet instead of re-firing the
132
+ // same error every due check. (Logged once, when the window was set.)
133
+ if (Date.now() < hb.backoffUntil) return;
72
134
  const lastRun = readHeartbeatState()?.last_run ?? 0;
73
135
  if (lastRun <= 0) {
74
136
  // Never ran on this install — fire now to establish the cadence.
@@ -178,6 +240,8 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
178
240
  status: "idle",
179
241
  run_count: previousRunCount + 1,
180
242
  });
243
+ hb.consecutiveFailures = 0;
244
+ hb.backoffUntil = 0;
181
245
  log(
182
246
  "heartbeat",
183
247
  `Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`,
@@ -199,6 +263,22 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
199
263
  status: "idle",
200
264
  run_count: isTimeout ? previousRunCount + 1 : previousRunCount,
201
265
  });
266
+ // Timeouts advance last_run (budget consumed), so the cadence itself
267
+ // spaces the next attempt. Every other failure retries against the same
268
+ // last_run — back off so the due check doesn't hammer it every minute.
269
+ if (!isTimeout) {
270
+ hb.consecutiveFailures += 1;
271
+ hb.backoffUntil = failureBackoffUntil(
272
+ err,
273
+ hb.consecutiveFailures,
274
+ Date.now(),
275
+ );
276
+ logWarn(
277
+ "heartbeat",
278
+ `Backing off until ${new Date(hb.backoffUntil).toISOString()} ` +
279
+ `after ${hb.consecutiveFailures} consecutive failure(s)`,
280
+ );
281
+ }
202
282
  if (trigger === "forced") throw err;
203
283
  } finally {
204
284
  hb.running = false;
@@ -71,6 +71,10 @@ export const hb: {
71
71
  intervalMinutesRef: number;
72
72
  config: HeartbeatConfig | null;
73
73
  logFileSequence: number;
74
+ /** Consecutive failed runs — drives the failure backoff curve. */
75
+ consecutiveFailures: number;
76
+ /** Epoch ms before which auto runs are suppressed after a failure. */
77
+ backoffUntil: number;
74
78
  } = {
75
79
  running: false,
76
80
  currentRunPromise: null,
@@ -79,6 +83,8 @@ export const hb: {
79
83
  intervalMinutesRef: 60,
80
84
  config: null,
81
85
  logFileSequence: 0,
86
+ consecutiveFailures: 0,
87
+ backoffUntil: 0,
82
88
  };
83
89
 
84
90
  // ── State-file I/O ───────────────────────────────────────────────────────────
@@ -73,7 +73,9 @@ export function isAutoInjectTrusted(
73
73
  /**
74
74
  * Enforce the #373 trust policy on a retrieval result: drop every item that
75
75
  * is not explicitly auto-inject trusted. Adapters should call this as their
76
- * last step so the filter cannot be forgotten upstream of the prompt.
76
+ * last step, and the Weaver's `prefetchMemory` applies it again to whatever
77
+ * a retriever returns — a buggy adapter cannot leak low-trust items into
78
+ * the prompt.
77
79
  */
78
80
  export function filterAutoInjectable(
79
81
  memory: RetrievedMemory | undefined,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Built-in plugin loading (GitHub / MemPalace / Playwright) + the hot-reload
2
+ * Built-in plugin loading (GitHub / MemPalace / mem0 / Playwright) + the hot-reload
3
3
  * path that re-reads config, tears down, and re-loads everything.
4
4
  */
5
5
 
@@ -13,7 +13,7 @@ import {
13
13
  } from "./loader.js";
14
14
 
15
15
  /**
16
- * Load built-in plugins (GitHub, MemPalace, Playwright) based on config flags.
16
+ * Load built-in plugins (GitHub, MemPalace, mem0, Playwright) based on config flags.
17
17
  * Shared by both bootstrap and hot-reload to avoid duplication.
18
18
  */
19
19
  export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
@@ -75,6 +75,34 @@ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
75
75
  }
76
76
  }
77
77
 
78
+ const mem0 = config.mem0;
79
+ if (mem0?.enabled) {
80
+ try {
81
+ const { createMem0Plugin } = await import("../../plugins/mem0/index.js");
82
+ const m0 = createMem0Plugin({
83
+ apiKey: mem0.apiKey,
84
+ host: mem0.host,
85
+ userId: mem0.userId,
86
+ });
87
+ const m0Config = mem0 as unknown as Record<string, unknown>;
88
+ const loaded = registerPlugin(m0, m0Config);
89
+ if (loaded) {
90
+ await initPluginWithTimeout(
91
+ loaded.plugin,
92
+ loaded.config,
93
+ 15_000,
94
+ "mem0 init",
95
+ "mem0 init",
96
+ );
97
+ }
98
+ } catch (err) {
99
+ logError(
100
+ "plugin",
101
+ `mem0 init: ${err instanceof Error ? err.message : err}`,
102
+ );
103
+ }
104
+ }
105
+
78
106
  const playwright = config.playwright;
79
107
  if (playwright?.enabled) {
80
108
  try {
@@ -16,22 +16,23 @@ import asset2 from "../../../prompts/discord.md" with { type: "file" };
16
16
  import asset3 from "../../../prompts/dream.md" with { type: "file" };
17
17
  import asset4 from "../../../prompts/heartbeat.md" with { type: "file" };
18
18
  import asset5 from "../../../prompts/identity.md" with { type: "file" };
19
- import asset6 from "../../../prompts/mempalace.md" with { type: "file" };
20
- import asset7 from "../../../prompts/native.md" with { type: "file" };
21
- import asset8 from "../../../prompts/system/contract-text-or-tools.md" with { type: "file" };
22
- import asset9 from "../../../prompts/system/contract-text-preferred.md" with { type: "file" };
23
- import asset10 from "../../../prompts/system/contract-tool-only.md" with { type: "file" };
24
- import asset11 from "../../../prompts/system/cron.md" with { type: "file" };
25
- import asset12 from "../../../prompts/system/daily-memory.md" with { type: "file" };
26
- import asset13 from "../../../prompts/system/goals.md" with { type: "file" };
27
- import asset14 from "../../../prompts/system/heartbeat-agent.md" with { type: "file" };
28
- import asset15 from "../../../prompts/system/persistent-memory.md" with { type: "file" };
29
- import asset16 from "../../../prompts/system/skills.md" with { type: "file" };
30
- import asset17 from "../../../prompts/system/triggers.md" with { type: "file" };
31
- import asset18 from "../../../prompts/system/workspace.md" with { type: "file" };
32
- import asset19 from "../../../prompts/teams.md" with { type: "file" };
33
- import asset20 from "../../../prompts/telegram.md" with { type: "file" };
34
- import asset21 from "../../../prompts/terminal.md" with { type: "file" };
19
+ import asset6 from "../../../prompts/mem0.md" with { type: "file" };
20
+ import asset7 from "../../../prompts/mempalace.md" with { type: "file" };
21
+ import asset8 from "../../../prompts/native.md" with { type: "file" };
22
+ import asset9 from "../../../prompts/system/contract-text-or-tools.md" with { type: "file" };
23
+ import asset10 from "../../../prompts/system/contract-text-preferred.md" with { type: "file" };
24
+ import asset11 from "../../../prompts/system/contract-tool-only.md" with { type: "file" };
25
+ import asset12 from "../../../prompts/system/cron.md" with { type: "file" };
26
+ import asset13 from "../../../prompts/system/daily-memory.md" with { type: "file" };
27
+ import asset14 from "../../../prompts/system/goals.md" with { type: "file" };
28
+ import asset15 from "../../../prompts/system/heartbeat-agent.md" with { type: "file" };
29
+ import asset16 from "../../../prompts/system/persistent-memory.md" with { type: "file" };
30
+ import asset17 from "../../../prompts/system/skills.md" with { type: "file" };
31
+ import asset18 from "../../../prompts/system/triggers.md" with { type: "file" };
32
+ import asset19 from "../../../prompts/system/workspace.md" with { type: "file" };
33
+ import asset20 from "../../../prompts/teams.md" with { type: "file" };
34
+ import asset21 from "../../../prompts/telegram.md" with { type: "file" };
35
+ import asset22 from "../../../prompts/terminal.md" with { type: "file" };
35
36
 
36
37
  /** rel path (posix, under prompts/) → embedded file path (/$bunfs/… when compiled). */
37
38
  const ASSETS: Record<string, string> = {
@@ -41,22 +42,23 @@ const ASSETS: Record<string, string> = {
41
42
  "dream.md": asset3,
42
43
  "heartbeat.md": asset4,
43
44
  "identity.md": asset5,
44
- "mempalace.md": asset6,
45
- "native.md": asset7,
46
- "system/contract-text-or-tools.md": asset8,
47
- "system/contract-text-preferred.md": asset9,
48
- "system/contract-tool-only.md": asset10,
49
- "system/cron.md": asset11,
50
- "system/daily-memory.md": asset12,
51
- "system/goals.md": asset13,
52
- "system/heartbeat-agent.md": asset14,
53
- "system/persistent-memory.md": asset15,
54
- "system/skills.md": asset16,
55
- "system/triggers.md": asset17,
56
- "system/workspace.md": asset18,
57
- "teams.md": asset19,
58
- "telegram.md": asset20,
59
- "terminal.md": asset21,
45
+ "mem0.md": asset6,
46
+ "mempalace.md": asset7,
47
+ "native.md": asset8,
48
+ "system/contract-text-or-tools.md": asset9,
49
+ "system/contract-text-preferred.md": asset10,
50
+ "system/contract-tool-only.md": asset11,
51
+ "system/cron.md": asset12,
52
+ "system/daily-memory.md": asset13,
53
+ "system/goals.md": asset14,
54
+ "system/heartbeat-agent.md": asset15,
55
+ "system/persistent-memory.md": asset16,
56
+ "system/skills.md": asset17,
57
+ "system/triggers.md": asset18,
58
+ "system/workspace.md": asset19,
59
+ "teams.md": asset20,
60
+ "telegram.md": asset21,
61
+ "terminal.md": asset22,
60
62
  };
61
63
 
62
64
  /** Read an embedded prompt by its rel path (e.g. "system/cron.md"). */
@@ -3,10 +3,16 @@
3
3
  * for live user messages, strictly fail-closed: a broken palace must
4
4
  * never block chat delivery. The result is dynamic turn context passed
5
5
  * through ChatRunParams; it never touches the frozen prompt.
6
+ *
7
+ * The #373 trust policy is enforced HERE, not just in adapters: whatever a
8
+ * retriever returns is passed through `filterAutoInjectable` before it can
9
+ * reach the prompt, so a buggy or future adapter cannot leak `user_claim` /
10
+ * `group_chat` items into auto-injection.
6
11
  */
7
12
 
8
13
  import type { RetrievedMemory } from "../agent-runtime/capabilities.js";
9
14
  import type { MemoryRetriever } from "../memory/retrieval.js";
15
+ import { filterAutoInjectable } from "../memory/retrieval.js";
10
16
  import { logDebug, logWarn } from "../../util/log.js";
11
17
 
12
18
  export type PrefetchMemoryInput = {
@@ -23,13 +29,23 @@ export async function prefetchMemory(
23
29
  input: PrefetchMemoryInput,
24
30
  ): Promise<RetrievedMemory | undefined> {
25
31
  try {
26
- const retrieved = await retrieve({
32
+ const raw = await retrieve({
27
33
  runKind: "chat",
28
34
  chatId: input.chatId,
29
35
  text: input.text,
30
36
  senderName: input.senderName,
31
37
  isGroup: input.isGroup,
32
38
  });
39
+ const retrieved = filterAutoInjectable(raw);
40
+ const droppedCount =
41
+ (raw?.items.length ?? 0) - (retrieved?.items.length ?? 0);
42
+ if (droppedCount > 0) {
43
+ logWarn(
44
+ "dispatcher",
45
+ `[${input.reqId}] memory pre-retrieval: dropped ${droppedCount} ` +
46
+ `low-trust item(s) the retriever failed to filter (#373)`,
47
+ );
48
+ }
33
49
  if (retrieved) {
34
50
  logDebug(
35
51
  "dispatcher",
@@ -208,9 +208,12 @@ export const messagingHandlers: DiscordActionHandlers = {
208
208
 
209
209
  schedule_message: (body, chatId, { client, scheduledMessages }) => {
210
210
  const text = String(body.text ?? "");
211
+ // NaN (e.g. delay_seconds: "5m") must fall back to the default, not
212
+ // propagate: setTimeout(fn, NaN) fires immediately.
213
+ const requested = Number(body.delay_seconds ?? 60);
211
214
  const delaySec = Math.max(
212
215
  1,
213
- Math.min(MAX_DELAY_SEC, Number(body.delay_seconds ?? 60)),
216
+ Math.min(MAX_DELAY_SEC, Number.isFinite(requested) ? requested : 60),
214
217
  );
215
218
  const entry: ScheduledMessage = {
216
219
  id: `sched_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
@@ -13,6 +13,7 @@ import type { TalonConfig } from "../../util/config.js";
13
13
  import type { Gateway } from "../../core/engine/gateway.js";
14
14
  import { resetSession, getAllSessions } from "../../storage/sessions.js";
15
15
  import { clearHistory } from "../../storage/history.js";
16
+ import { todayLogDate } from "../../storage/daily-log.js";
16
17
  import { getChatSettings } from "../../storage/chat-settings.js";
17
18
  import {
18
19
  getAllCronJobs,
@@ -165,7 +166,7 @@ export async function handleAdminSubcommand(
165
166
  }
166
167
 
167
168
  case "daily": {
168
- const today = new Date().toISOString().slice(0, 10);
169
+ const today = todayLogDate();
169
170
  const logPath = `${dirs.logs}/${today}.md`;
170
171
  try {
171
172
  const content = readFileSync(logPath, "utf-8");
@@ -243,9 +243,12 @@ export const messagingHandlers: TelegramActionHandlers = {
243
243
  const text = String(body.text ?? "");
244
244
  const replyTo = toPositiveId(body.reply_to_message_id);
245
245
  const rows = body.rows as ScheduledMessage["rows"];
246
+ // NaN (e.g. delay_seconds: "5m") must fall back to the default, not
247
+ // propagate: setTimeout(fn, NaN) fires immediately.
248
+ const requested = Number(body.delay_seconds ?? 60);
246
249
  const delaySec = Math.max(
247
250
  1,
248
- Math.min(MAX_DELAY_SEC, Number(body.delay_seconds ?? 60)),
251
+ Math.min(MAX_DELAY_SEC, Number.isFinite(requested) ? requested : 60),
249
252
  );
250
253
  const entry: ScheduledMessage = {
251
254
  id: `sched_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
@@ -10,6 +10,7 @@ import { tailFile } from "../../util/tail-file.js";
10
10
  import { escapeHtml } from "./formatting.js";
11
11
  import { resetSession, getAllSessions } from "../../storage/sessions.js";
12
12
  import { clearHistory } from "../../storage/history.js";
13
+ import { todayLogDate } from "../../storage/daily-log.js";
13
14
  import { getChatSettings } from "../../storage/chat-settings.js";
14
15
  import {
15
16
  getAllCronJobs,
@@ -221,7 +222,7 @@ export async function handleAdminCommand(
221
222
  }
222
223
 
223
224
  case "daily": {
224
- const today = new Date().toISOString().slice(0, 10);
225
+ const today = todayLogDate();
225
226
  const logPath = `${dirs.logs}/${today}.md`;
226
227
  try {
227
228
  const content = readFileSync(logPath, "utf-8");
@@ -0,0 +1,88 @@
1
+ /**
2
+ * mem0 plugin — long-term memory via the mem0 platform (https://mem0.ai).
3
+ *
4
+ * Registers a stdio MCP server (server.ts) bridging the `mem0ai` SDK,
5
+ * giving the agent semantic memory add/search/list/get/delete. Works
6
+ * against the hosted platform (API key) or a self-hosted mem0 server
7
+ * (host URL).
8
+ *
9
+ * Preferred configuration in ~/.talon/config.json:
10
+ * "memory": {
11
+ * "enabled": true,
12
+ * "backend": "mem0",
13
+ * "mem0": {
14
+ * "apiKey": "m0-...", // default: MEM0_API_KEY env var
15
+ * "host": "http://localhost:8888", // optional self-hosted server
16
+ * "userId": "talon" // optional entity id (default "talon")
17
+ * }
18
+ * }
19
+ */
20
+
21
+ import { readFileSync } from "node:fs";
22
+ import { resolve } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import type { TalonPlugin } from "../../core/plugin/index.js";
25
+ import { log, logWarn } from "../../util/log.js";
26
+ import { dirs } from "../../util/paths.js";
27
+
28
+ /** Load from ~/.talon/prompts/ (user-customisable, seeded on first run) */
29
+ const PROMPT_PATH = resolve(dirs.prompts, "mem0.md");
30
+
31
+ const SERVER_PATH = fileURLToPath(new URL("./server.ts", import.meta.url));
32
+
33
+ export function createMem0Plugin(config: {
34
+ /** Platform API key. Falls back to the MEM0_API_KEY env var. */
35
+ apiKey?: string;
36
+ /** Self-hosted mem0 server URL; when set, apiKey may be omitted. */
37
+ host?: string;
38
+ /** Entity id memories are filed under (default "talon"). */
39
+ userId?: string;
40
+ }): TalonPlugin {
41
+ const apiKey = config.apiKey?.trim() || process.env.MEM0_API_KEY?.trim();
42
+ const host = config.host?.trim();
43
+ const userId = config.userId?.trim() || "talon";
44
+
45
+ return {
46
+ name: "mem0",
47
+ description: "mem0 — long-term memory layer (hosted or self-hosted)",
48
+ version: "1.0.0",
49
+
50
+ mcpServerPath: SERVER_PATH,
51
+
52
+ validateConfig() {
53
+ if (!apiKey && !host) {
54
+ return [
55
+ 'mem0 requires an API key ("memory.mem0.apiKey" or the MEM0_API_KEY env var) or a self-hosted "memory.mem0.host" URL.',
56
+ ];
57
+ }
58
+ return undefined;
59
+ },
60
+
61
+ init() {
62
+ log(
63
+ "mem0",
64
+ `Ready (${host ? `host: ${host}` : "hosted platform"}, user: ${userId})`,
65
+ );
66
+ },
67
+
68
+ getEnvVars() {
69
+ const env: Record<string, string> = { MEM0_USER_ID: userId };
70
+ if (apiKey) env.MEM0_API_KEY = apiKey;
71
+ if (host) env.MEM0_HOST = host;
72
+ return env;
73
+ },
74
+
75
+ getSystemPromptAddition() {
76
+ try {
77
+ const template = readFileSync(PROMPT_PATH, "utf-8");
78
+ return template.replace(/\{\{userId\}\}/g, userId);
79
+ } catch (err) {
80
+ logWarn(
81
+ "mem0",
82
+ `Failed to load prompt from ${PROMPT_PATH}: ${err instanceof Error ? err.message : err}`,
83
+ );
84
+ return `## mem0 — Long-term Memory\n\nMemory entity id: \`${userId}\``;
85
+ }
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * mem0 MCP server — stdio subprocess spawned by the plugin loader.
3
+ *
4
+ * Thin bridge over the `mem0ai` MemoryClient (hosted platform or a
5
+ * self-hosted server via MEM0_HOST). All memories are scoped to one
6
+ * entity (MEM0_USER_ID) so the palace of a single Talon deployment
7
+ * stays isolated inside a shared mem0 project.
8
+ *
9
+ * Env contract (set by src/plugins/mem0/index.ts getEnvVars):
10
+ * MEM0_API_KEY — platform API key (required unless MEM0_HOST is set)
11
+ * MEM0_HOST — optional self-hosted API base URL
12
+ * MEM0_USER_ID — entity id memories are filed under (default "talon")
13
+ */
14
+
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { z } from "zod";
18
+ import MemoryClient from "mem0ai";
19
+
20
+ const apiKey = process.env.MEM0_API_KEY ?? "";
21
+ const host = process.env.MEM0_HOST;
22
+ const userId = process.env.MEM0_USER_ID || "talon";
23
+
24
+ const client = new MemoryClient({ apiKey, ...(host ? { host } : {}) });
25
+
26
+ /** v3 search/list scope every call to this deployment's entity. */
27
+ const entityFilter = { AND: [{ user_id: userId }] };
28
+
29
+ type ToolResult = {
30
+ content: Array<{ type: "text"; text: string }>;
31
+ isError?: boolean;
32
+ };
33
+
34
+ function textResult(value: unknown): ToolResult {
35
+ return {
36
+ content: [
37
+ {
38
+ type: "text",
39
+ text: typeof value === "string" ? value : JSON.stringify(value),
40
+ },
41
+ ],
42
+ };
43
+ }
44
+
45
+ function errorResult(err: unknown): ToolResult {
46
+ return {
47
+ content: [
48
+ {
49
+ type: "text",
50
+ text: `mem0 error: ${err instanceof Error ? err.message : String(err)}`,
51
+ },
52
+ ],
53
+ isError: true,
54
+ };
55
+ }
56
+
57
+ const server = new McpServer({ name: "mem0-tools", version: "1.0.0" });
58
+
59
+ server.tool(
60
+ "mem0_add_memory",
61
+ "Store new information in mem0 long-term memory. Pass conversational text; mem0 extracts and files the durable facts itself.",
62
+ {
63
+ text: z.string().min(1).describe("The information to remember"),
64
+ role: z
65
+ .enum(["user", "assistant"])
66
+ .optional()
67
+ .describe("Who the information came from (default user)"),
68
+ metadata: z
69
+ .record(z.string(), z.string())
70
+ .optional()
71
+ .describe("Optional key/value tags stored with the memory"),
72
+ },
73
+ async ({ text, role, metadata }) => {
74
+ try {
75
+ const memories = await client.add(
76
+ [{ role: role ?? "user", content: text }],
77
+ {
78
+ userId,
79
+ ...(metadata ? { metadata } : {}),
80
+ },
81
+ );
82
+ return textResult({ stored: memories.length, memories });
83
+ } catch (err) {
84
+ return errorResult(err);
85
+ }
86
+ },
87
+ );
88
+
89
+ server.tool(
90
+ "mem0_search_memory",
91
+ "Semantic search over mem0 memories. Use short keyword queries, not full sentences.",
92
+ {
93
+ query: z.string().min(1).describe("Search query"),
94
+ limit: z
95
+ .number()
96
+ .int()
97
+ .min(1)
98
+ .max(50)
99
+ .optional()
100
+ .describe("Max results (default 10)"),
101
+ threshold: z
102
+ .number()
103
+ .min(0)
104
+ .max(1)
105
+ .optional()
106
+ .describe("Minimum relevance score, 0-1"),
107
+ },
108
+ async ({ query, limit, threshold }) => {
109
+ try {
110
+ const { results } = await client.search(query, {
111
+ filters: entityFilter,
112
+ topK: limit ?? 10,
113
+ ...(threshold !== undefined ? { threshold } : {}),
114
+ });
115
+ return textResult({ count: results.length, results });
116
+ } catch (err) {
117
+ return errorResult(err);
118
+ }
119
+ },
120
+ );
121
+
122
+ server.tool(
123
+ "mem0_list_memories",
124
+ "Browse stored mem0 memories with pagination. Use for inventory/cleanup, not search.",
125
+ {
126
+ page: z
127
+ .number()
128
+ .int()
129
+ .min(1)
130
+ .optional()
131
+ .describe("Page number (default 1)"),
132
+ page_size: z
133
+ .number()
134
+ .int()
135
+ .min(1)
136
+ .max(100)
137
+ .optional()
138
+ .describe("Results per page (default 25)"),
139
+ },
140
+ async ({ page, page_size }) => {
141
+ try {
142
+ const result = await client.getAll({
143
+ filters: entityFilter,
144
+ page: page ?? 1,
145
+ pageSize: page_size ?? 25,
146
+ });
147
+ return textResult(result);
148
+ } catch (err) {
149
+ return errorResult(err);
150
+ }
151
+ },
152
+ );
153
+
154
+ server.tool(
155
+ "mem0_get_memory",
156
+ "Fetch a single mem0 memory by id, with full content and metadata.",
157
+ { memory_id: z.string().min(1).describe("Memory id from search/list") },
158
+ async ({ memory_id }) => {
159
+ try {
160
+ return textResult(await client.get(memory_id));
161
+ } catch (err) {
162
+ return errorResult(err);
163
+ }
164
+ },
165
+ );
166
+
167
+ server.tool(
168
+ "mem0_delete_memory",
169
+ "Delete a mem0 memory by id when it is wrong or stale.",
170
+ { memory_id: z.string().min(1).describe("Memory id from search/list") },
171
+ async ({ memory_id }) => {
172
+ try {
173
+ return textResult(await client.delete(memory_id));
174
+ } catch (err) {
175
+ return errorResult(err);
176
+ }
177
+ },
178
+ );
179
+
180
+ await server.connect(new StdioServerTransport());
@@ -104,6 +104,14 @@ export function getLogsDir(): string {
104
104
  return LOGS_DIR;
105
105
  }
106
106
 
107
+ /**
108
+ * Today's log-file date key. Readers must use this (not the UTC date from
109
+ * `toISOString`) or they look for the wrong file whenever local date ≠ UTC.
110
+ */
111
+ export function todayLogDate(): string {
112
+ return localDateKey(new Date());
113
+ }
114
+
107
115
  /** Matches YYYY-MM-DD.md filenames strictly. */
108
116
  const DAILY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
109
117
 
@@ -195,6 +195,32 @@ const playwrightConfigSchema = z.object({
195
195
  endpointFile: z.string().optional(),
196
196
  });
197
197
 
198
+ /** MemPalace backend settings, shared by `memory.mempalace` and the legacy top-level `mempalace` section. */
199
+ const mempalaceSettingsSchema = z.object({
200
+ /** Palace directory path (default: ~/.talon/workspace/palace/) */
201
+ palacePath: z.string().min(1).optional(),
202
+ /** Python binary path (default: ~/.talon/mempalace-venv/bin/python) */
203
+ pythonPath: z.string().min(1).optional(),
204
+ /**
205
+ * BCP 47 language codes for entity detection (mempalace >= 3.3).
206
+ * Supported: en, es, fr, de, ja, ko, zh-CN, zh-TW, pt-br, ru, it, hi, id.
207
+ * Sets MEMPALACE_ENTITY_LANGUAGES for the MCP server.
208
+ */
209
+ entityLanguages: z.array(z.string().min(2)).nonempty().optional(),
210
+ /** Enable mempalace diagnostic diaries (sets MEMPAL_VERBOSE=1). */
211
+ verbose: z.boolean().optional(),
212
+ });
213
+
214
+ /** mem0 backend settings, shared by `memory.mem0` and the top-level `mem0` section. */
215
+ const mem0SettingsSchema = z.object({
216
+ /** Platform API key (default: MEM0_API_KEY env var). */
217
+ apiKey: z.string().min(1).optional(),
218
+ /** Self-hosted mem0 server URL; when set, apiKey may be omitted. */
219
+ host: z.string().min(1).optional(),
220
+ /** Entity id memories are filed under (default "talon"). */
221
+ userId: z.string().min(1).optional(),
222
+ });
223
+
198
224
  /**
199
225
  * Memory pre-retrieval (Phase B) — automatic per-turn palace retrieval.
200
226
  * Ships INERT: the flag gates wiring a real retriever into the dispatcher.
@@ -383,25 +409,30 @@ const configSchema = z.object({
383
409
  })
384
410
  .optional(),
385
411
 
386
- // MemPalacestructured long-term memory with vector search
387
- mempalace: z
412
+ // Long-term memory unified backend selection. Preferred over the
413
+ // legacy top-level "mempalace"/"mem0" sections; when enabled it wins
414
+ // over them (loadConfig mirrors it onto the section the loaders read).
415
+ memory: z
388
416
  .object({
389
417
  enabled: z.boolean().default(false),
390
- /** Palace directory path (default: ~/.talon/workspace/palace/) */
391
- palacePath: z.string().min(1).optional(),
392
- /** Python binary path (default: ~/.talon/mempalace-venv/bin/python) */
393
- pythonPath: z.string().min(1).optional(),
394
- /**
395
- * BCP 47 language codes for entity detection (mempalace >= 3.3).
396
- * Supported: en, es, fr, de, ja, ko, zh-CN, zh-TW, pt-br, ru, it, hi, id.
397
- * Sets MEMPALACE_ENTITY_LANGUAGES for the MCP server.
398
- */
399
- entityLanguages: z.array(z.string().min(2)).nonempty().optional(),
400
- /** Enable mempalace diagnostic diaries (sets MEMPAL_VERBOSE=1). */
401
- verbose: z.boolean().optional(),
418
+ backend: z.enum(["mempalace", "mem0"]).default("mempalace"),
419
+ mempalace: mempalaceSettingsSchema.optional(),
420
+ mem0: mem0SettingsSchema.optional(),
402
421
  })
403
422
  .optional(),
404
423
 
424
+ // MemPalace — structured long-term memory with vector search
425
+ // (legacy section; prefer "memory": { "backend": "mempalace", ... })
426
+ mempalace: mempalaceSettingsSchema
427
+ .extend({ enabled: z.boolean().default(false) })
428
+ .optional(),
429
+
430
+ // mem0 — long-term memory layer, hosted platform or self-hosted
431
+ // (legacy-style section; prefer "memory": { "backend": "mem0", ... })
432
+ mem0: mem0SettingsSchema
433
+ .extend({ enabled: z.boolean().default(false) })
434
+ .optional(),
435
+
405
436
  // Playwright — headless browser automation via MCP
406
437
  playwright: playwrightConfigSchema.optional(),
407
438
 
@@ -577,6 +608,25 @@ export function loadConfig(): TalonConfig {
577
608
 
578
609
  const parsed = configSchema.parse(fileConfig);
579
610
 
611
+ // Unified memory section: mirror the selected backend onto the per-plugin
612
+ // section the loaders consume (builtins.ts reads config.mempalace /
613
+ // config.mem0). The memory section wins over a legacy section when both
614
+ // are present; the unselected backend is disabled so exactly one memory
615
+ // plugin loads.
616
+ if (parsed.memory?.enabled) {
617
+ if (parsed.memory.backend === "mempalace") {
618
+ parsed.mempalace = {
619
+ ...parsed.mempalace,
620
+ ...parsed.memory.mempalace,
621
+ enabled: true,
622
+ };
623
+ if (parsed.mem0) parsed.mem0.enabled = false;
624
+ } else {
625
+ parsed.mem0 = { ...parsed.mem0, ...parsed.memory.mem0, enabled: true };
626
+ if (parsed.mempalace) parsed.mempalace.enabled = false;
627
+ }
628
+ }
629
+
580
630
  // Apply timezone globally before building the system prompt
581
631
  setTimezone(parsed.timezone);
582
632
 
package/src/util/log.ts CHANGED
@@ -54,6 +54,7 @@ export type LogComponent =
54
54
  | "access"
55
55
  | "github"
56
56
  | "mempalace"
57
+ | "mem0"
57
58
  | "playwright"
58
59
  | "soul"
59
60
  | "stickers"