talon-agent 1.38.1 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.38.1",
3
+ "version": "1.40.0",
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());
@@ -46,4 +46,12 @@ export const meshHandlers: SharedActionHandlers = {
46
46
  body.local_path,
47
47
  body.remote_path,
48
48
  ),
49
+ // Remote self-update: push a new APK and silently (re)install it, keeping
50
+ // the mesh connection across the app restart.
51
+ update_device: (body) =>
52
+ getMeshService().updateDeviceApp(
53
+ body.device,
54
+ body.apk_path,
55
+ body.remote_path,
56
+ ),
49
57
  };
@@ -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,
@@ -27,7 +27,9 @@
27
27
  */
28
28
 
29
29
  import { randomUUID } from "node:crypto";
30
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
30
+ import { createHash } from "node:crypto";
31
+ import { createReadStream } from "node:fs";
32
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
31
33
  import { tmpdir } from "node:os";
32
34
  import { basename, dirname, join, resolve } from "node:path";
33
35
  import type { Readable } from "node:stream";
@@ -758,6 +760,87 @@ export class MeshService {
758
760
  };
759
761
  }
760
762
 
763
+ /**
764
+ * `update_device`: remote self-update for the companion. Streams a new APK
765
+ * to the device, then tells it to silently install (via Shizuku) and
766
+ * restart. The mesh foreground service's autoRunOnMyPackageReplaced brings
767
+ * the connection back on its own — the link drops only for the seconds the
768
+ * process is swapped, no manual reopen.
769
+ *
770
+ * The APK is hashed here and the digest travels with the install command;
771
+ * the device re-hashes the pushed file and refuses to install on a
772
+ * mismatch, so a truncated transfer can never be installed.
773
+ */
774
+ async updateDeviceApp(
775
+ query: unknown,
776
+ localApkPath: unknown,
777
+ remotePath?: unknown,
778
+ ): Promise<MeshToolResult> {
779
+ const local =
780
+ typeof localApkPath === "string" && localApkPath.trim()
781
+ ? resolve(dirs.workspace, localApkPath.trim())
782
+ : "";
783
+ if (!local) return { ok: false, text: "A local APK path is required." };
784
+ await this.load();
785
+ const resolved = this.resolveDevice(query);
786
+ if ("error" in resolved) return { ok: false, text: resolved.error };
787
+ const target = resolved.target;
788
+ if (target.capabilities && !target.capabilities.includes("install_apk")) {
789
+ return {
790
+ ok: false,
791
+ text: `${target.name} can't self-update — it needs a companion build with the install_apk capability and Shizuku enabled (device control on).`,
792
+ };
793
+ }
794
+
795
+ let sha256: string;
796
+ let size: number;
797
+ try {
798
+ ({ sha256, size } = await hashFile(local));
799
+ } catch (err) {
800
+ return {
801
+ ok: false,
802
+ text: `Cannot read APK ${local}: ${(err as Error).message}`,
803
+ };
804
+ }
805
+ if (size === 0) {
806
+ return { ok: false, text: `APK ${local} is empty.` };
807
+ }
808
+
809
+ const remote =
810
+ typeof remotePath === "string" && remotePath.trim()
811
+ ? remotePath.trim()
812
+ : "/sdcard/Download/talon-companion-update.apk";
813
+
814
+ // 1. Stream the APK to the device.
815
+ const push = await this.pushFileToDevice(target.id, local, remote);
816
+ if (!push.ok) {
817
+ return { ok: false, text: `Update aborted — push failed: ${push.text}` };
818
+ }
819
+
820
+ // 2. Trigger the silent install (device verifies the digest first).
821
+ const dispatched = await this.dispatchCommand(
822
+ target.id,
823
+ "install_apk",
824
+ { path: remote, sha256 },
825
+ this.commandTimeoutMs,
826
+ );
827
+ if ("error" in dispatched) return { ok: false, text: dispatched.error };
828
+ if (!dispatched.result.ok) {
829
+ return {
830
+ ok: false,
831
+ text:
832
+ dispatched.result.message ?? `${target.name} refused the install.`,
833
+ };
834
+ }
835
+ return {
836
+ ok: true,
837
+ text:
838
+ `Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
839
+ `${dispatched.result.message ?? "Installing now."} ` +
840
+ `Confirm with get_device_status once it reconnects (appVersion should change).`,
841
+ };
842
+ }
843
+
761
844
  /**
762
845
  * Chunked read of a remote file into a Buffer. Loops `read_file` with
763
846
  * increasing offsets until the device reports EOF.
@@ -1246,6 +1329,22 @@ function formatBytes(bytes: number): string {
1246
1329
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1247
1330
  }
1248
1331
 
1332
+ /** Stream a file through SHA-256 without loading it into memory (APKs are big
1333
+ * and Buffer has a hard ceiling). Returns the hex digest and byte size. */
1334
+ async function hashFile(
1335
+ path: string,
1336
+ ): Promise<{ sha256: string; size: number }> {
1337
+ const { size } = await stat(path);
1338
+ const hash = createHash("sha256");
1339
+ await new Promise<void>((resolve, reject) => {
1340
+ createReadStream(path)
1341
+ .on("data", (chunk) => hash.update(chunk))
1342
+ .on("end", () => resolve())
1343
+ .on("error", reject);
1344
+ });
1345
+ return { sha256: hash.digest("hex"), size };
1346
+ }
1347
+
1249
1348
  // ── Process-wide instance ─────────────────────────────────────────────────────
1250
1349
 
1251
1350
  let instance: MeshService | null = null;
@@ -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"). */
@@ -150,4 +150,25 @@ export const meshTools: ToolDefinition[] = [
150
150
  execute: (params, bridge) => bridge("device_push_file", params),
151
151
  tag: "mesh",
152
152
  },
153
+ {
154
+ name: "update_device",
155
+ description:
156
+ "Remotely update a Talon companion (Android): stream a new APK from the daemon to the device and silently reinstall it via Shizuku, keeping app data. The companion's mesh runs in a foreground service that auto-restarts after the package is replaced, so the connection returns on its own within seconds — no manual reopen. The APK is hashed and the device verifies it before installing (a truncated push is refused; a differently-signed APK is refused by Android). Requires the device to have device control on and Shizuku granted. Confirm success with get_device_status afterwards (appVersion should change).",
157
+ schema: {
158
+ device: deviceParam,
159
+ apk_path: z
160
+ .string()
161
+ .describe(
162
+ "Path to the new APK, relative to the workspace (or absolute).",
163
+ ),
164
+ remote_path: z
165
+ .string()
166
+ .optional()
167
+ .describe(
168
+ "Where to stage the APK on the device (default /sdcard/Download/talon-companion-update.apk).",
169
+ ),
170
+ },
171
+ execute: (params, bridge) => bridge("update_device", params),
172
+ tag: "mesh",
173
+ },
153
174
  ];
@@ -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"