talon-agent 3.7.0 → 3.8.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 (42) hide show
  1. package/README.md +16 -0
  2. package/package.json +2 -2
  3. package/prompts/README.md +10 -2
  4. package/prompts/dream.md +5 -3
  5. package/prompts/heartbeat.md +1 -1
  6. package/prompts/identity.md +1 -1
  7. package/prompts/mem0.md +7 -5
  8. package/prompts/mempalace.md +7 -5
  9. package/prompts/system/memory-recall.md +49 -0
  10. package/prompts/system/workspace.md +2 -2
  11. package/src/backend/claude-sdk/one-shot.ts +31 -1
  12. package/src/backend/codex/effort.ts +30 -0
  13. package/src/backend/codex/handler/message.ts +12 -22
  14. package/src/backend/codex/one-shot.ts +20 -0
  15. package/src/backend/opencode/handler/message.ts +7 -13
  16. package/src/backend/remote-server/one-shot.ts +6 -0
  17. package/src/backend/shared/handler-to-events.ts +0 -1
  18. package/src/backend/shared/handler-types.ts +0 -8
  19. package/src/backend/shared/index.ts +1 -6
  20. package/src/backend/shared/prompt-format.ts +0 -69
  21. package/src/bootstrap.ts +2 -0
  22. package/src/core/agent-runtime/capabilities.ts +5 -49
  23. package/src/core/background/dream.ts +27 -2
  24. package/src/core/background/effort.ts +80 -0
  25. package/src/core/background/heartbeat/agent.ts +22 -1
  26. package/src/core/background/heartbeat/state.ts +7 -0
  27. package/src/core/engine/model-audit.ts +69 -2
  28. package/src/core/errors.ts +107 -3
  29. package/src/core/models/reasoning-levels.ts +14 -3
  30. package/src/core/prompt/assemble.ts +8 -5
  31. package/src/core/prompt/embedded-prompts.ts +16 -14
  32. package/src/core/types.ts +10 -0
  33. package/src/core/weaver/index.ts +0 -1
  34. package/src/core/weaver/weaver.ts +1 -24
  35. package/src/frontend/telegram/callbacks/index.ts +8 -0
  36. package/src/frontend/telegram/callbacks/metrics.ts +36 -0
  37. package/src/frontend/telegram/commands/admin.ts +9 -13
  38. package/src/frontend/telegram/commands/info.ts +3 -44
  39. package/src/frontend/telegram/helpers/diagnostics.ts +190 -70
  40. package/src/util/config.ts +34 -19
  41. package/src/core/memory/retrieval.ts +0 -92
  42. package/src/core/weaver/memory-prefetch.ts +0 -65
@@ -35,54 +35,16 @@ import type {
35
35
 
36
36
  // ── Run parameters ──────────────────────────────────────────────────────────
37
37
 
38
- /**
39
- * Provenance trust level of a retrieved memory item, per the memory-poisoning
40
- * threat model (#373). Only the first three levels are ever eligible for
41
- * automatic injection; `user_claim` and `group_chat` content must stay
42
- * pull-only (explicit search), never auto-injected.
43
- */
44
- export type RetrievedMemoryTrustLevel =
45
- | "dylan_direct" // stated by the operator in a verified DM
46
- | "bot_inferred" // inferred by the bot from code/docs/verified primary source
47
- | "heartbeat_synthesis" // synthesized in a background run, no external input
48
- | "user_claim" // claimed by a non-operator user, unverified
49
- | "group_chat"; // sourced from group chat content
50
-
51
- /** One retrieved memory fragment with its provenance. */
52
- export interface RetrievedMemoryItem {
53
- /** Palace wing (top-level category), e.g. "technical". */
54
- wing: string;
55
- /** Palace room within the wing, when known. */
56
- room?: string;
57
- /** Source file locator, when known (e.g. "memory-phase-b.md"). */
58
- sourceFile?: string;
59
- /** The retrieved text fragment. */
60
- text: string;
61
- /** Retrieval relevance score, when the retriever provides one. */
62
- score?: number;
63
- /** Provenance trust level; absent means unknown (treat as untrusted). */
64
- trustLevel?: RetrievedMemoryTrustLevel;
65
- }
66
-
67
- /**
68
- * A bounded, sanitized slice of long-term memory retrieved for one turn.
69
- * This is DYNAMIC turn context: it is injected into the live user prompt by
70
- * the backend prompt formatter and must never enter `prepareSystemPrompt()`
71
- * output, frozen prompt snapshots, plugin prompt additions, or backend
72
- * `system` fields — that would break the prompt-cache contract.
73
- */
74
- export interface RetrievedMemory {
75
- source: "mempalace";
76
- /** The (possibly trimmed) query the retriever ran. */
77
- query: string;
78
- items: RetrievedMemoryItem[];
79
- }
80
-
81
38
  /**
82
39
  * Parameters for a chat turn. `model` is a resolved `ModelRef`,
83
40
  * carrying everything the backend needs to identify the model and
84
41
  * render the resulting reply. Streaming callbacks aren't part of
85
42
  * this shape — backends emit `AgentEvent`s.
43
+ *
44
+ * Long-term memory is deliberately NOT part of this shape. `memory.md` is
45
+ * loaded once into the cached system prompt (`core/prompt/assemble.ts`);
46
+ * anything deeper the model searches for itself with the MemPalace tools.
47
+ * Nothing is auto-injected into a turn.
86
48
  */
87
49
  export interface ChatRunParams {
88
50
  chatId: string;
@@ -92,12 +54,6 @@ export interface ChatRunParams {
92
54
  isGroup?: boolean;
93
55
  /** Provider message ID. Telegram is numeric; Discord snowflakes are strings. */
94
56
  messageId?: number | string;
95
- /**
96
- * Optional pre-retrieved memory slice for this turn. Backends fold it into
97
- * the live user prompt (after the cached system prompt), never into
98
- * `system` — see `formatPromptWithRetrievedMemory`.
99
- */
100
- retrievedMemory?: RetrievedMemory;
101
57
  }
102
58
 
103
59
  // ── Catalog types ───────────────────────────────────────────────────────────
@@ -19,10 +19,11 @@ import { importLegacyJson } from "../../storage/legacy-import.js";
19
19
  import { readPromptAsset } from "#prompt-assets";
20
20
  import { log, logError, logWarn } from "../../util/log.js";
21
21
  import { getDefaultModel } from "../models/catalog.js";
22
- import type { OneShotAgentParams } from "../types.js";
22
+ import type { OneShotAgentParams, ReasoningEffortLevel } from "../types.js";
23
23
  import type { Backend } from "../agent-runtime/capabilities.js";
24
24
  import { getSoul } from "../soul/service.js";
25
25
  import { taskTable } from "../tasks/index.js";
26
+ import { resolveBackgroundEffort } from "./effort.js";
26
27
  import { FailureBackoff } from "./failure-backoff.js";
27
28
 
28
29
  // ── Types ────────────────────────────────────────────────────────────────────
@@ -65,6 +66,8 @@ export const dreamFailureBackoff = new FailureBackoff();
65
66
  let configRef: {
66
67
  model?: string;
67
68
  dreamModel?: string;
69
+ /** Reasoning effort for dream runs. Undefined = backend/model default. */
70
+ dreamEffort?: ReasoningEffortLevel;
68
71
  workspace?: string;
69
72
  /** When false, `maybeStartDream` never fires (config `dream: false`). */
70
73
  enabled?: boolean;
@@ -86,6 +89,12 @@ export function initDream(cfg: {
86
89
  model?: string;
87
90
  /** Override model for dream consolidation (e.g. a cheaper model). Falls back to main model. */
88
91
  dreamModel?: string;
92
+ /**
93
+ * Reasoning effort for dream consolidation (config `dreamEffort`). Unset
94
+ * leaves the backend/model default in place. Ignored by backends with no
95
+ * reasoning knob (Kilo, OpenCode).
96
+ */
97
+ dreamEffort?: ReasoningEffortLevel;
89
98
  workspace?: string;
90
99
  /** Gate for automatic dream runs — config `dream` flag. Defaults to enabled. */
91
100
  enabled?: boolean;
@@ -242,13 +251,28 @@ If commands fail, log the error and continue — this stage is optional.`
242
251
  );
243
252
  }
244
253
 
254
+ // Resolved against the dream backend's catalog — an effort level the model
255
+ // doesn't offer is dropped with a reason instead of reaching the SDK.
256
+ const effort = await resolveBackgroundEffort({
257
+ requested: configRef.dreamEffort,
258
+ model,
259
+ backend,
260
+ });
261
+ if (effort.dropped) {
262
+ logWarn("dream", effort.dropped);
263
+ }
264
+
245
265
  // Set up dream log file
246
266
  const dreamLogFile = createDreamLogFile();
247
267
  appendDreamLog(dreamLogFile, `# Dream Run — ${new Date().toISOString()}\n`);
248
268
  appendDreamLog(
249
269
  dreamLogFile,
250
- `**Trigger:** last_run=${lastRunIso}, model=${model}\n`,
270
+ `**Trigger:** last_run=${lastRunIso}, model=${model}` +
271
+ `${effort.effort ? `, effort=${effort.effort}` : ""}\n`,
251
272
  );
273
+ if (effort.dropped) {
274
+ appendDreamLog(dreamLogFile, `**Effort:** ${effort.dropped}\n`);
275
+ }
252
276
  appendDreamLog(
253
277
  dreamLogFile,
254
278
  `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`,
@@ -272,6 +296,7 @@ If commands fail, log the error and continue — this stage is optional.`
272
296
  systemPrompt,
273
297
  workspace,
274
298
  model,
299
+ ...(effort.effort ? { reasoningEffort: effort.effort } : {}),
275
300
  contextLabel: "dream",
276
301
  abortController,
277
302
  // appendDreamLog is sync (writeFileSync) — wrap to satisfy the async
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Reasoning-effort resolution for background runs (heartbeat, dream).
3
+ *
4
+ * Both background agents take an effort level from config
5
+ * (`heartbeatEffort` / `dreamEffort`). Two separate questions hide behind
6
+ * "apply that level", and they belong to different layers:
7
+ *
8
+ * 1. *Is the level available on this model?* — a model-capability
9
+ * question. Every backend catalog already answers it through the
10
+ * `models.getRawModelInfo` capability (`supportedReasoningLevels`),
11
+ * which is the same channel the frontends' effort pickers read. So it
12
+ * is answered here, once, against the `Backend` abstraction.
13
+ *
14
+ * 2. *How is the level expressed to the provider?* — Claude thinking
15
+ * options, Codex `modelReasoningEffort`, nothing at all for
16
+ * Kilo/OpenCode. That is adapter work and stays inside each backend's
17
+ * one-shot runner.
18
+ *
19
+ * Keeping (1) here is what stops four one-shot runners from each growing
20
+ * their own copy of the same catalog lookup.
21
+ */
22
+
23
+ import type { Backend } from "../agent-runtime/capabilities.js";
24
+ import type { ReasoningEffortLevel } from "../types.js";
25
+ import {
26
+ normalizeReasoningLevels,
27
+ supportsReasoningLevel,
28
+ } from "../models/reasoning-levels.js";
29
+
30
+ export type BackgroundEffortResolution = {
31
+ /** The level to pass to the backend, or undefined to use its default. */
32
+ effort?: ReasoningEffortLevel;
33
+ /**
34
+ * Set when a configured level was discarded — a log-ready explanation of
35
+ * what was asked for and why the run proceeds without it. A dropped level
36
+ * never fails the run: a stale effort setting shouldn't cost an operator
37
+ * their hourly heartbeat.
38
+ */
39
+ dropped?: string;
40
+ };
41
+
42
+ /**
43
+ * Resolve the effort level a background run should ask for.
44
+ *
45
+ * Unset config → `{}` (backend/model default, i.e. what background runs did
46
+ * before the knob existed). A backend with no catalog capability, an
47
+ * unreachable catalog, or a model that reports no level metadata all pass
48
+ * the level through untouched: absent metadata is not evidence that the
49
+ * level is unsupported, and the adapter still has the final say.
50
+ */
51
+ export async function resolveBackgroundEffort(params: {
52
+ requested: ReasoningEffortLevel | undefined;
53
+ model: string;
54
+ backend: Backend | null;
55
+ }): Promise<BackgroundEffortResolution> {
56
+ const { requested, model, backend } = params;
57
+ if (!requested) return {};
58
+
59
+ const catalog = backend?.models;
60
+ if (!catalog) return { effort: requested };
61
+
62
+ let info;
63
+ try {
64
+ info = await catalog.getRawModelInfo(model);
65
+ } catch {
66
+ return { effort: requested }; // catalog unavailable — don't second-guess
67
+ }
68
+
69
+ const levels = normalizeReasoningLevels(info?.supportedReasoningLevels);
70
+ if (levels.length === 0) return { effort: requested };
71
+
72
+ if (!supportsReasoningLevel(requested, levels)) {
73
+ return {
74
+ dropped:
75
+ `configured effort "${requested}" is not available on "${model}" ` +
76
+ `(supports: ${levels.join(", ")}) — running the model default`,
77
+ };
78
+ }
79
+ return { effort: requested };
80
+ }
@@ -14,6 +14,7 @@ import { loadSystemTemplate } from "../../prompt/templates.js";
14
14
  import { formatGoal, getOpenGoals } from "../../../storage/goal-store.js";
15
15
  import { taskTable } from "../../tasks/index.js";
16
16
  import type { OneShotAgentParams } from "../../types.js";
17
+ import { resolveBackgroundEffort } from "../effort.js";
17
18
  import { hb } from "./state.js";
18
19
 
19
20
  const DEFAULT_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000; // 10-minute soft cap
@@ -164,6 +165,18 @@ export async function runHeartbeatAgent(
164
165
  );
165
166
  }
166
167
 
168
+ // Effort is resolved against the heartbeat backend's catalog, not just
169
+ // copied from config — a level the model doesn't offer is dropped with a
170
+ // reason rather than handed to the SDK.
171
+ const effort = await resolveBackgroundEffort({
172
+ requested: config.heartbeatEffort,
173
+ model,
174
+ backend,
175
+ });
176
+ if (effort.dropped) {
177
+ logWarn("heartbeat", effort.dropped);
178
+ }
179
+
167
180
  // Set up heartbeat log file
168
181
  const heartbeatLogFile = await createHeartbeatLogFile();
169
182
  await appendHeartbeatLog(
@@ -172,8 +185,15 @@ export async function runHeartbeatAgent(
172
185
  );
173
186
  await appendHeartbeatLog(
174
187
  heartbeatLogFile,
175
- `**Trigger:** ${lastRunIso === "never" ? "first run" : `last_run=${lastRunIso}`}, model=${model}\n`,
188
+ `**Trigger:** ${lastRunIso === "never" ? "first run" : `last_run=${lastRunIso}`}, model=${model}` +
189
+ `${effort.effort ? `, effort=${effort.effort}` : ""}\n`,
176
190
  );
191
+ if (effort.dropped) {
192
+ await appendHeartbeatLog(
193
+ heartbeatLogFile,
194
+ `**Effort:** ${effort.dropped}\n`,
195
+ );
196
+ }
177
197
  await appendHeartbeatLog(
178
198
  heartbeatLogFile,
179
199
  `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`,
@@ -197,6 +217,7 @@ export async function runHeartbeatAgent(
197
217
  systemPrompt: buildHeartbeatSystemPrompt(),
198
218
  workspace,
199
219
  model,
220
+ ...(effort.effort ? { reasoningEffort: effort.effort } : {}),
200
221
  contextLabel: "heartbeat",
201
222
  abortController,
202
223
  appendLog: (text) => appendHeartbeatLog(heartbeatLogFile, text),
@@ -10,6 +10,7 @@ import { files as pathFiles } from "../../../util/paths.js";
10
10
  import { kvGet, kvSet } from "../../../storage/kv.js";
11
11
  import { importLegacyJson } from "../../../storage/legacy-import.js";
12
12
  import type { Backend } from "../../agent-runtime/capabilities.js";
13
+ import type { ReasoningEffortLevel } from "../../types.js";
13
14
  import { FailureBackoff } from "../failure-backoff.js";
14
15
 
15
16
  export type HeartbeatState = {
@@ -28,6 +29,12 @@ export type HeartbeatState = {
28
29
  export type HeartbeatConfig = {
29
30
  model?: string;
30
31
  heartbeatModel?: string;
32
+ /**
33
+ * Reasoning effort for heartbeat runs (config `heartbeatEffort`).
34
+ * Undefined = backend/model default. Passed straight through to the
35
+ * one-shot params; backends without a reasoning knob ignore it.
36
+ */
37
+ heartbeatEffort?: ReasoningEffortLevel;
31
38
  workspace?: string;
32
39
  /**
33
40
  * Accessor for the active backend — invoked each time a heartbeat fires so
@@ -20,14 +20,20 @@
20
20
 
21
21
  import type { Backend } from "../agent-runtime/capabilities.js";
22
22
  import type { TalonConfig } from "../../util/config.js";
23
+ import type { ReasoningEffortLevel } from "../types.js";
24
+ import {
25
+ normalizeReasoningLevels,
26
+ supportsReasoningLevel,
27
+ } from "../models/reasoning-levels.js";
23
28
 
24
29
  export type ModelAuditRole = "chat" | "heartbeat" | "dream";
25
30
 
26
31
  export type ModelAuditFinding = {
27
32
  role: ModelAuditRole;
28
33
  backendId: string;
34
+ /** The config value the finding is about — a model id, or an effort level for `unsupported-effort`. */
29
35
  configured: string;
30
- kind: "missing" | "ambiguous";
36
+ kind: "missing" | "ambiguous" | "unsupported-effort";
31
37
  /** Human-readable, log-ready description with the fix. */
32
38
  message: string;
33
39
  };
@@ -39,6 +45,15 @@ const CONFIG_KEY: Record<ModelAuditRole, string> = {
39
45
  dream: "dreamModel",
40
46
  };
41
47
 
48
+ /**
49
+ * Effort config keys, for the roles that have one. Chat effort is a
50
+ * per-chat setting (`/settings`), not config, so it isn't audited here.
51
+ */
52
+ const EFFORT_KEY: Partial<Record<ModelAuditRole, string>> = {
53
+ heartbeat: "heartbeatEffort",
54
+ dream: "dreamEffort",
55
+ };
56
+
42
57
  /**
43
58
  * Audit each role's configured model against its backend's catalog.
44
59
  *
@@ -55,22 +70,25 @@ export async function auditConfiguredModels(
55
70
  role: ModelAuditRole;
56
71
  model: string | undefined;
57
72
  backendId: string;
73
+ effort?: ReasoningEffortLevel;
58
74
  }> = [
59
75
  { role: "chat", model: config.model, backendId: config.backend },
60
76
  {
61
77
  role: "heartbeat",
62
78
  model: config.heartbeatModel,
63
79
  backendId: config.heartbeatBackend ?? config.backend,
80
+ effort: config.heartbeatEffort,
64
81
  },
65
82
  {
66
83
  role: "dream",
67
84
  model: config.dreamModel,
68
85
  backendId: config.dreamBackend ?? config.backend,
86
+ effort: config.dreamEffort,
69
87
  },
70
88
  ];
71
89
 
72
90
  const findings: ModelAuditFinding[] = [];
73
- for (const { role, model, backendId } of targets) {
91
+ for (const { role, model, backendId, effort } of targets) {
74
92
  if (!model || model === "default") continue;
75
93
 
76
94
  let backend: Backend | undefined;
@@ -115,7 +133,56 @@ export async function auditConfiguredModels(
115
133
  `"${backendId}" (matches: ${names}) — pin an exact id in ` +
116
134
  `"${CONFIG_KEY[role]}" in config.json.`,
117
135
  });
136
+ } else if (resolution.kind === "exact") {
137
+ const finding = auditEffort(
138
+ role,
139
+ backendId,
140
+ effort,
141
+ resolution.model.supportedReasoningLevels,
142
+ );
143
+ if (finding) findings.push(finding);
118
144
  }
119
145
  }
120
146
  return findings;
121
147
  }
148
+
149
+ /**
150
+ * Check a role's configured effort level against what the resolved model
151
+ * advertises.
152
+ *
153
+ * Same "silently runs something else" failure mode as a withdrawn model:
154
+ * an effort the model doesn't offer is dropped at run time, and without
155
+ * this the operator only learns that from a heartbeat log up to an hour
156
+ * later (up to twelve, for dream).
157
+ *
158
+ * Returns undefined — no finding — whenever the answer isn't knowable:
159
+ * no effort configured, no role-level effort key, or a model that reports
160
+ * no level metadata at all (absent metadata is not evidence of absence).
161
+ * Note this only runs for roles with a PINNED model; an effort set against
162
+ * an unpinned model can't be checked because there's no id to resolve.
163
+ */
164
+ function auditEffort(
165
+ role: ModelAuditRole,
166
+ backendId: string,
167
+ effort: ReasoningEffortLevel | undefined,
168
+ advertised: readonly ReasoningEffortLevel[] | undefined,
169
+ ): ModelAuditFinding | undefined {
170
+ const key = EFFORT_KEY[role];
171
+ if (!effort || !key) return undefined;
172
+
173
+ const levels = normalizeReasoningLevels(advertised);
174
+ if (levels.length === 0) return undefined;
175
+ if (supportsReasoningLevel(effort, levels)) return undefined;
176
+
177
+ return {
178
+ role,
179
+ backendId,
180
+ configured: effort,
181
+ kind: "unsupported-effort",
182
+ message:
183
+ `${role}: configured effort "${effort}" is NOT available on the ` +
184
+ `model pinned for this role on backend "${backendId}" ` +
185
+ `(supports: ${levels.join(", ")}) — runs will use the model default. ` +
186
+ `Update "${key}" in config.json.`,
187
+ };
188
+ }
@@ -60,6 +60,68 @@ export class TalonError extends Error {
60
60
  }
61
61
  }
62
62
 
63
+ // ── Transport-failure detection ─────────────────────────────────────────────
64
+
65
+ /**
66
+ * Node / undici error codes that mean "the connection failed, try again".
67
+ *
68
+ * Codes beat message text: they're stable across Node versions and
69
+ * locales, and undici in particular throws bare `TypeError: terminated`
70
+ * / `TypeError: fetch failed` whose message says nothing while the
71
+ * `code` on the cause chain says everything.
72
+ *
73
+ * `ENOTFOUND` is deliberately absent — a DNS name that doesn't resolve
74
+ * is a config error, not a blip. `EAI_AGAIN` (temporary resolver
75
+ * failure) IS here, because that one does clear.
76
+ */
77
+ const RETRYABLE_TRANSPORT_CODES: ReadonlySet<string> = new Set([
78
+ "ECONNRESET",
79
+ "ECONNREFUSED",
80
+ "ECONNABORTED",
81
+ "EPIPE",
82
+ "ETIMEDOUT",
83
+ "EHOSTUNREACH",
84
+ "ENETUNREACH",
85
+ "ENETDOWN",
86
+ "EAI_AGAIN",
87
+ "UND_ERR_CONNECT_TIMEOUT",
88
+ "UND_ERR_HEADERS_TIMEOUT",
89
+ "UND_ERR_BODY_TIMEOUT",
90
+ "UND_ERR_SOCKET",
91
+ "ERR_STREAM_PREMATURE_CLOSE",
92
+ "ERR_SOCKET_CONNECTION_TIMEOUT",
93
+ ]);
94
+
95
+ /**
96
+ * Transient transport failures that surface as prose with no usable
97
+ * `code` — Node's own `socket hang up`, undici's `other side closed` /
98
+ * `Premature close`, and the named 5xx bodies proxies return as text.
99
+ *
100
+ * `timeout`/`timed out` is here but bare `abort` deliberately is NOT: a
101
+ * user interrupt (`controller.abort()` → "This operation was aborted")
102
+ * must stay non-retryable, or cancelling a turn would restart it.
103
+ * `AbortSignal.timeout()` says "aborted due to timeout" and is caught by
104
+ * the timeout half, which is the distinction we want.
105
+ */
106
+ const TRANSIENT_TRANSPORT_RE =
107
+ /socket hang up|other side closed|premature close|connection (?:closed|reset|lost)|timed out|time-?out|bad gateway|service unavailable|gateway time-?out|internal server error|upstream connect error|server disconnected/i;
108
+
109
+ /**
110
+ * Walk the `cause` chain collecting `code` / `errno` / `name` values.
111
+ * undici nests the real fault one or two levels down (`TypeError: fetch
112
+ * failed` → `cause: Error { code: 'ECONNREFUSED' }`), so a shallow look
113
+ * at the thrown object misses it entirely.
114
+ */
115
+ function errorCodes(err: unknown, depth = 0): string[] {
116
+ if (depth > 5 || err === null || typeof err !== "object") return [];
117
+ const e = err as { code?: unknown; errno?: unknown; cause?: unknown };
118
+ const codes: string[] = [];
119
+ if (typeof e.code === "string") codes.push(e.code);
120
+ if (typeof e.errno === "string") codes.push(e.errno);
121
+ codes.push(...errorCodes(e.cause, depth + 1));
122
+ return codes;
123
+ }
124
+
63
125
  // ── Classify any error ──────────────────────────────────────────────────────
64
126
 
65
127
  /**
@@ -115,6 +177,27 @@ export function classify(err: unknown): TalonError {
115
177
  });
116
178
  }
117
179
 
180
+ // Transport failure by CODE, before any prose matching. undici wraps
181
+ // the real fault ("TypeError: fetch failed" → cause.code) so the
182
+ // message alone is often empty of signal while the code is decisive.
183
+ //
184
+ // `TimeoutError` (what AbortSignal.timeout throws) is a transient
185
+ // deadline and retries. `AbortError` is NOT handled here on purpose —
186
+ // it means a user interrupt, and retrying a cancelled turn would
187
+ // restart work the user just stopped.
188
+ const errName = err instanceof Error ? err.name : "";
189
+ const transportCode = errorCodes(err).find((code) =>
190
+ RETRYABLE_TRANSPORT_CODES.has(code),
191
+ );
192
+ if (transportCode !== undefined || errName === "TimeoutError") {
193
+ return new TalonError(msg, {
194
+ reason: "network",
195
+ retryable: true,
196
+ retryAfterMs: 2_000,
197
+ cause,
198
+ });
199
+ }
200
+
118
201
  // Overloaded / capacity
119
202
  if (/overloaded|503|capacity/i.test(msg)) {
120
203
  return new TalonError(msg, {
@@ -126,11 +209,19 @@ export function classify(err: unknown): TalonError {
126
209
  });
127
210
  }
128
211
 
129
- // Network errors
212
+ // Network errors — codes echoed into the message text (a stringified
213
+ // cause, a provider wrapping the errno into prose), plus the
214
+ // code-less transient shapes in TRANSIENT_TRANSPORT_RE.
215
+ //
216
+ // The prose half only applies when NO HTTP status was found. A real
217
+ // status is the stronger signal and its own branches below own it:
218
+ // "500 Internal Server Error" must stay `overloaded` carrying
219
+ // status 500, not become a status-less `network`.
130
220
  if (
131
- /network|ECONNREFUSED|ECONNRESET|ECONNABORTED|ETIMEDOUT|ENOTFOUND|fetch failed|connection reset/i.test(
221
+ /network|ECONNREFUSED|ECONNRESET|ECONNABORTED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|UND_ERR_|fetch failed|connection reset/i.test(
132
222
  msg,
133
- )
223
+ ) ||
224
+ (status === undefined && TRANSIENT_TRANSPORT_RE.test(msg))
134
225
  ) {
135
226
  return new TalonError(msg, {
136
227
  reason: "network",
@@ -140,6 +231,19 @@ export function classify(err: unknown): TalonError {
140
231
  });
141
232
  }
142
233
 
234
+ // 408 Request Timeout — a transient deadline like any other, but it
235
+ // is neither 4xx-terminal nor 5xx, so it used to fall through to
236
+ // `unknown`/non-retryable and strand the request.
237
+ if (status === 408) {
238
+ return new TalonError(msg, {
239
+ reason: "network",
240
+ retryable: true,
241
+ status: 408,
242
+ retryAfterMs: 2_000,
243
+ cause,
244
+ });
245
+ }
246
+
143
247
  // Session expired
144
248
  if (/session.*expired|expired.*session|invalid.*resume/i.test(msg)) {
145
249
  return new TalonError(msg, {
@@ -1,13 +1,24 @@
1
1
  import type { ReasoningEffortLevel } from "../types.js";
2
2
 
3
+ /**
4
+ * Ascending ladder, weakest reasoning first. This is the ONLY ordering in
5
+ * the codebase: `normalizeReasoningLevels` re-sorts a model's advertised
6
+ * levels through it, so every effort picker (Telegram, Discord, native)
7
+ * renders in this sequence.
8
+ *
9
+ * `xhigh` (Codex's ceiling) sits below `max` (Claude's ceiling) so the row
10
+ * reads low → medium → high → xhigh → max. A single model advertises one
11
+ * ceiling or the other, never both, so their relative position only ever
12
+ * matters for how the ladder reads.
13
+ */
3
14
  export const REASONING_LEVEL_ORDER: ReasoningEffortLevel[] = [
4
15
  "off",
5
16
  "minimal",
6
17
  "low",
7
18
  "medium",
8
19
  "high",
9
- "max",
10
20
  "xhigh",
21
+ "max",
11
22
  ];
12
23
 
13
24
  export const REASONING_LEVEL_LABELS: Record<ReasoningEffortLevel, string> = {
@@ -16,8 +27,8 @@ export const REASONING_LEVEL_LABELS: Record<ReasoningEffortLevel, string> = {
16
27
  low: "Low",
17
28
  medium: "Med",
18
29
  high: "High",
19
- max: "Max",
20
30
  xhigh: "XHigh",
31
+ max: "Max",
21
32
  };
22
33
 
23
34
  export const REASONING_LEVEL_DESCRIPTIONS: Record<
@@ -29,8 +40,8 @@ export const REASONING_LEVEL_DESCRIPTIONS: Record<
29
40
  low: "short reasoning pass",
30
41
  medium: "balanced reasoning",
31
42
  high: "deeper reasoning, slower",
32
- max: "maximum Claude reasoning budget",
33
43
  xhigh: "maximum Codex reasoning budget",
44
+ max: "maximum Claude reasoning budget",
34
45
  adaptive: "use the model/backend default",
35
46
  };
36
47
 
@@ -18,7 +18,7 @@
18
18
  * 3. Frontend capabilities ~/.talon/prompts/<frontend>.md
19
19
  * 4. Persistent memory (size-capped) prompts/system/persistent-memory.md
20
20
  * wrapping ~/.talon/workspace/memory/memory.md
21
- * 5. Workspace / cron / triggers docs prompts/system/{workspace,cron,triggers}.md
21
+ * 5. Memory recall + capability docs prompts/system/{memory-recall,workspace,...}.md
22
22
  * 6. Plugin additions plugin.systemPrompt() contributions
23
23
  * (7. Delivery contract — appended by the backend as its suffix,
24
24
  * AFTER plugins, so it is the last thing the model reads.
@@ -207,11 +207,14 @@ export function assembleSystemPrompt(
207
207
  loaded.push(truncated ? "memory(capped)" : "memory");
208
208
  }
209
209
 
210
- // 5. Capability docs — workspace layout, cron, triggers, goals,
211
- // skills. Concise by design: per-tool protocols and examples
212
- // live in the MCP tool descriptions, which the model also has
213
- // in context.
210
+ // 5. Package-owned behavioural and capability docs. The memory policy
211
+ // is deliberately package-owned so custom identity/base prompts cannot
212
+ // remove recall-before-asking or adaptive persistence behaviour.
213
+ // Provider-specific additions follow in step 6 and become canonical
214
+ // when their tools are available; otherwise the policy falls back to
215
+ // memory.md + daily notes.
214
216
  staticParts.push(
217
+ loadSystemTemplate("memory-recall"),
215
218
  loadSystemTemplate("workspace"),
216
219
  loadSystemTemplate("cron"),
217
220
  loadSystemTemplate("triggers"),