niahere 0.5.0 → 0.5.2

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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/agent/backends/claude-normalize.ts +41 -1
  3. package/src/agent/backends/claude.ts +2 -1
  4. package/src/agent/backends/codex-normalize.ts +20 -6
  5. package/src/agent/backends/codex.ts +3 -2
  6. package/src/agent/failure.ts +3 -1
  7. package/src/agent/mcp-endpoint.ts +3 -2
  8. package/src/channels/common/chat-session.ts +60 -0
  9. package/src/channels/phone/consult.ts +2 -1
  10. package/src/channels/phone/index.ts +5 -3
  11. package/src/channels/phone/relay.ts +15 -5
  12. package/src/channels/slack.ts +24 -34
  13. package/src/channels/sms.ts +16 -36
  14. package/src/channels/telegram.ts +22 -34
  15. package/src/channels/twilio/media-cache.ts +4 -3
  16. package/src/channels/twilio/rest.ts +0 -31
  17. package/src/channels/twilio/server.ts +0 -5
  18. package/src/channels/twilio/shared.ts +36 -0
  19. package/src/channels/whatsapp.ts +24 -58
  20. package/src/chat/engine.ts +17 -8
  21. package/src/chat/gap-marker.ts +63 -0
  22. package/src/chat/repl.ts +5 -5
  23. package/src/cli/config.ts +71 -0
  24. package/src/cli/index.ts +23 -288
  25. package/src/cli/job.ts +1 -2
  26. package/src/cli/logs.ts +55 -0
  27. package/src/cli/run.ts +74 -0
  28. package/src/cli/skills.ts +13 -0
  29. package/src/cli/status.ts +0 -1
  30. package/src/cli/test.ts +32 -0
  31. package/src/cli/update.ts +79 -0
  32. package/src/commands/backup.ts +1 -2
  33. package/src/commands/init.ts +1 -2
  34. package/src/core/alive.ts +4 -3
  35. package/src/core/consolidator.ts +78 -31
  36. package/src/core/daemon.ts +3 -2
  37. package/src/core/finalizer.ts +9 -5
  38. package/src/core/runner.ts +7 -6
  39. package/src/core/scheduler.ts +19 -4
  40. package/src/core/skills.ts +0 -4
  41. package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
  42. package/src/db/models/active_engine.ts +0 -7
  43. package/src/db/models/job.ts +3 -2
  44. package/src/db/models/message.ts +10 -0
  45. package/src/db/models/session.ts +69 -15
  46. package/src/mcp/tools/misc.ts +2 -1
  47. package/src/mcp/tools/send.ts +5 -4
  48. package/src/utils/errors.ts +26 -0
@@ -0,0 +1,79 @@
1
+ import { isRunning, readPid } from "../utils/pid";
2
+ import { errMsg } from "../utils/errors";
3
+ import { guardActiveEngines, parseGuardFlags, withDefaultWait } from "../core/engine-guard";
4
+ import { startDaemon, stopDaemon } from "../core/daemon";
5
+
6
+ /** `nia update` — upgrade the global install, then restart if the daemon is up. */
7
+ export async function updateCommand(argv: string[]): Promise<void> {
8
+ const guard = withDefaultWait(parseGuardFlags(argv), 1);
9
+ const { version: currentVersion } = await import("../../package.json");
10
+ console.log(`Current: v${currentVersion}`);
11
+
12
+ // Never upgrade out from under a live turn.
13
+ if (isRunning() && !(await guardActiveEngines("update", guard))) process.exit(1);
14
+
15
+ try {
16
+ const { createBackup } = await import("../commands/backup");
17
+ console.log("Backing up...");
18
+ await createBackup(true);
19
+ console.log("✓ pre-update backup created");
20
+ } catch (err) {
21
+ console.log(`⚠ backup skipped: ${errMsg(err)}`);
22
+ }
23
+
24
+ console.log("Updating...");
25
+ const install = Bun.spawn(["npm", "i", "-g", "niahere@latest"], { stdio: ["ignore", "inherit", "inherit"] });
26
+ if ((await install.exited) !== 0) {
27
+ console.error("Update failed.");
28
+ process.exit(1);
29
+ }
30
+
31
+ const check = Bun.spawn(["npm", "view", "niahere", "version"], { stdout: "pipe", stderr: "pipe" });
32
+ const newVersion = (await new Response(check.stdout).text()).trim();
33
+ await check.exited;
34
+
35
+ if (newVersion === currentVersion) {
36
+ console.log("Already on latest.");
37
+ return;
38
+ }
39
+
40
+ console.log(`Updated: v${currentVersion} → v${newVersion}`);
41
+ if (!isRunning()) return;
42
+
43
+ console.log("Restarting daemon...");
44
+ const { isServiceInstalled, restartService } = await import("../commands/service");
45
+ if (isServiceInstalled()) {
46
+ await restartService({ force: guard.force });
47
+ } else {
48
+ stopDaemon({ force: guard.force });
49
+ startDaemon();
50
+ }
51
+ console.log("Restarted.");
52
+ }
53
+
54
+ /** `nia channels <on|off> [name]` — toggle channels and nudge a running daemon. */
55
+ export async function channelsToggleCommand(sub: string, target?: string): Promise<void> {
56
+ const { updateRawConfig } = await import("../utils/config");
57
+ const SUPPORTED = new Set(["telegram", "slack", "phone", "sms", "whatsapp"]);
58
+ const enabled = sub === "on";
59
+
60
+ if (target) {
61
+ if (!SUPPORTED.has(target)) {
62
+ console.error(`Usage: nia channels <on|off> [${[...SUPPORTED].join("|")}]`);
63
+ process.exit(1);
64
+ }
65
+ updateRawConfig({ channels: { ...(enabled ? { enabled: true } : {}), [target]: { enabled } } });
66
+ } else {
67
+ updateRawConfig({ channels: { enabled } });
68
+ }
69
+
70
+ const what = target ?? "channels";
71
+ const state = enabled ? "enabled" : "disabled";
72
+ const pid = readPid();
73
+ if (pid && isRunning()) {
74
+ process.kill(pid, "SIGHUP");
75
+ console.log(`${what} ${state}`);
76
+ } else {
77
+ console.log(`${what} ${state} — start nia to apply`);
78
+ }
79
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
2
2
  import { join, basename } from "path";
3
3
  import { getNiaHome } from "../utils/paths";
4
4
  import { getConfig } from "../utils/config";
@@ -79,7 +79,6 @@ export async function createBackup(silent = false): Promise<string> {
79
79
  if (exitCode === 0 && existsSync(dumpPath)) {
80
80
  // Copy to a relative path for tar
81
81
  const relDump = "db-backup.sql";
82
- const { copyFileSync } = await import("fs");
83
82
  copyFileSync(dumpPath, join(home, relDump));
84
83
  includes.push(relDump);
85
84
  dbDumped = true;
@@ -1,5 +1,5 @@
1
1
  import * as readline from "readline";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "fs";
2
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { resolve } from "path";
4
4
  import { homedir } from "os";
5
5
  import { getNiaHome, getPaths } from "../utils/paths";
@@ -436,7 +436,6 @@ export async function runInit(): Promise<void> {
436
436
  }
437
437
  } else if (hasDefaultReference) {
438
438
  // No description — copy defaults
439
- const { copyFileSync } = await import("fs");
440
439
  copyFileSync(`${SKILL_ASSETS_DIR}/nia-reference.webp`, `${imagesDir}/reference.webp`);
441
440
  console.log(` \u2713 copied default reference image`);
442
441
  if (existsSync(`${SKILL_ASSETS_DIR}/nia-profile.webp`)) {
package/src/core/alive.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { log } from "../utils/log";
2
+ import { homedir } from "os";
3
+ import { existsSync, readFileSync, unlinkSync } from "fs";
4
+ import { errMsg } from "../utils/errors";
2
5
  import { getConfig } from "../utils/config";
3
6
  import { getSql, closeDb } from "../db/connection";
4
7
  import { reconcileChannels } from "../channels";
@@ -23,7 +26,6 @@ async function recoverPostgres(): Promise<boolean> {
23
26
  log.info("alive: postgres not ready, attempting deterministic recovery");
24
27
 
25
28
  // Find and remove stale postmaster.pid
26
- const { existsSync, unlinkSync, readFileSync } = await import("fs");
27
29
  for (const dir of PG_DATA_DIRS) {
28
30
  const pidFile = `${dir}/postmaster.pid`;
29
31
  if (!existsSync(pidFile)) continue;
@@ -126,7 +128,6 @@ async function notifyUser(message: string): Promise<void> {
126
128
  async function runRecoveryAgent(failures: Check[]): Promise<{ recovered: boolean; report: string }> {
127
129
  try {
128
130
  const { runOneShot } = await import("./runner");
129
- const { homedir } = await import("os");
130
131
 
131
132
  const failureSummary = failures.map((f) => `- ${f.name}: ${f.detail}`).join("\n");
132
133
 
@@ -160,7 +161,7 @@ async function runRecoveryAgent(failures: Check[]): Promise<{ recovered: boolean
160
161
  } catch (err) {
161
162
  return {
162
163
  recovered: false,
163
- report: `Recovery agent failed to run: ${err instanceof Error ? err.message : String(err)}`,
164
+ report: `Recovery agent failed to run: ${errMsg(err)}`,
164
165
  };
165
166
  }
166
167
  }
@@ -10,36 +10,69 @@
10
10
  * See AGENTS.md > "Two-stage memory" for the full architecture.
11
11
  */
12
12
 
13
- import { Message } from "../db/models";
13
+ import { Message, Session } from "../db/models";
14
14
  import { runTask } from "./runner";
15
15
  import { log } from "../utils/log";
16
16
  import type { SessionMessage } from "../types";
17
17
 
18
- /** Bounded dedup: sessionId → message count at last consolidation. Prevents re-processing
19
- * the same messages while allowing re-consolidation when new turns arrive. */
20
- const processedCounts = new Map<string, number>();
21
18
  const inFlight = new Set<string>();
22
- const MAX_TRACKED = 500;
23
19
 
24
- /** Max messages to include in transcript (most recent). Keeps prompt size bounded. */
20
+ /** Max messages in one transcript. Keeps the prompt bounded. */
25
21
  const MAX_TRANSCRIPT_MESSAGES = 50;
26
22
 
23
+ /** New turns required before a session already consolidated is looked at again.
24
+ * Without it, a long-running room re-sends its whole window on every message. */
25
+ export const MIN_NEW_MESSAGES = 6;
26
+
27
+ /** Prior turns replayed alongside new ones, so a learning that spans the
28
+ * boundary is not cut in half. */
29
+ export const CONTEXT_TAIL = 5;
30
+
31
+ export interface ConsolidationPlan {
32
+ run: boolean;
33
+ /** Index into the session's messages to start the transcript from. */
34
+ from: number;
35
+ }
36
+
37
+ /**
38
+ * Decide whether a session is worth (re-)reading and over what window.
39
+ *
40
+ * A session is always read once — a correction can be two turns, and skipping
41
+ * short sessions starves the pipeline. After that it takes a real batch of new
42
+ * turns to justify another pass, and only the new tail is re-read.
43
+ */
44
+ export function planConsolidation(total: number, consolidated: number): ConsolidationPlan {
45
+ const floor = Math.max(0, total - MAX_TRANSCRIPT_MESSAGES);
46
+
47
+ if (consolidated <= 0) {
48
+ return { run: total >= 2, from: floor };
49
+ }
50
+ if (total - consolidated < MIN_NEW_MESSAGES) {
51
+ return { run: false, from: floor };
52
+ }
53
+ return { run: true, from: Math.max(floor, consolidated - CONTEXT_TAIL) };
54
+ }
55
+
27
56
  /** Rooms to skip (placeholder sessions). */
28
57
  function shouldSkip(room: string): boolean {
29
58
  return room.includes("placeholder");
30
59
  }
31
60
 
32
- /** Format conversation transcript for the extraction prompt. Cap to recent messages. */
33
- function formatTranscript(messages: SessionMessage[]): string {
34
- const recent = messages.slice(-MAX_TRANSCRIPT_MESSAGES);
35
- const skipped = messages.length - recent.length;
36
- const prefix = skipped > 0 ? `[...${skipped} earlier messages omitted]\n\n` : "";
37
-
38
- return prefix + recent.map((m) => `[${m.sender}] (${m.createdAt}): ${m.content.slice(0, 2000)}`).join("\n\n");
61
+ /** Format the transcript window for the extraction prompt. */
62
+ function formatTranscript(messages: SessionMessage[], from: number, consolidated: number): string {
63
+ const window = messages.slice(from);
64
+ const prefix = from > 0 ? `[...${from} earlier messages already consolidated, omitted]\n\n` : "";
65
+ const seen = Math.max(0, consolidated - from);
66
+ const marker = seen > 0 ? `\n\n--- everything below is NEW since the last pass ---\n` : "";
67
+
68
+ const lines = window.map(
69
+ (m, i) => `${i === seen && marker ? marker : ""}[${m.sender}] (${m.createdAt}): ${m.content.slice(0, 2000)}`,
70
+ );
71
+ return prefix + lines.join("\n\n");
39
72
  }
40
73
 
41
74
  /** Build the consolidation prompt from a conversation transcript. */
42
- function buildConsolidationPrompt(transcript: string, source: string): string {
75
+ export function buildConsolidationPrompt(transcript: string, source: string): string {
43
76
  const today = new Date().toISOString().slice(0, 10);
44
77
  return `Job: memory-consolidation (triggered by ${source})
45
78
 
@@ -91,11 +124,27 @@ stop here and do not write anything.
91
124
  (Includes: "we're using X not Y", config changes, deployment patterns)
92
125
  4. What did the user explicitly ask to be remembered?
93
126
 
94
- Trivial small talk, greetings, and pure status updates are NOT answers.
95
- But corrections made DURING task execution ("no, check DynamoDB not S3"),
127
+ Corrections made DURING task execution ("no, check DynamoDB not S3"),
96
128
  architecture learned while debugging ("ah, this service talks to X via Y"),
97
129
  and workflow patterns revealed by how the user works — these ARE answers.
98
- The bar is: would a fresh Nia session benefit from knowing this?
130
+
131
+ **The bar is durability: would this still be true and useful in 30 days?**
132
+ "Would a fresh session benefit" is too weak — it admits anything mildly
133
+ relevant. A fact that expires with the week is noise once it expires.
134
+
135
+ Never stage any of these, however interesting they seemed in the moment:
136
+
137
+ - **Transient state** — what a service was doing today, a current error, a
138
+ job's latest output, "X is down". True this hour, misleading next month.
139
+ - **One-off events** — a single incident, a one-time request, "we deployed
140
+ at 3pm". A pattern needs repetition; one occurrence is an anecdote.
141
+ - **Derivable facts** — anything already in the repo, config, git history,
142
+ or \`nia status\`. If a future session can look it up, it does not need
143
+ remembering.
144
+ - **Task chatter** — what was done this session, progress reports, what to
145
+ do next. That belongs in the work, not in who the user is.
146
+ - **Restatements** — a rewording of something in \`memory.md\` or \`rules.md\`.
147
+ Reinforce the existing entry instead (Step 3.2).
99
148
 
100
149
  ## Step 3 — Update staging.md
101
150
 
@@ -128,6 +177,8 @@ For each substantive answer:
128
177
  - Do NOT message the user.
129
178
  - If nothing qualifies, do nothing. But don't be so conservative that the
130
179
  pipeline starves — if you're skipping every session, your bar is too high.
180
+ Most sessions should yield zero or one candidate. Several is suspicious;
181
+ re-check them against the exclusion list before writing.
131
182
 
132
183
  Report a one-line summary of what you did: "staged N new / reinforced M /
133
184
  skipped (trivial session)". No preamble.`;
@@ -155,26 +206,22 @@ export async function consolidateSession(sessionId: string, room: string): Promi
155
206
 
156
207
  try {
157
208
  const messages = await Message.getBySession(sessionId);
158
- if (messages.length < 2) return;
159
-
160
- // Skip if already processed this exact message count
161
- if (processedCounts.get(sessionId) === messages.length) return;
209
+ const consolidated = await Session.getConsolidatedCount(sessionId);
210
+ const plan = planConsolidation(messages.length, consolidated);
211
+ if (!plan.run) return;
162
212
 
163
213
  inFlight.add(sessionId);
164
214
 
165
- log.info({ sessionId, room, messageCount: messages.length }, "consolidator: extracting memories from chat");
215
+ log.info(
216
+ { sessionId, room, messageCount: messages.length, consolidated, from: plan.from },
217
+ "consolidator: extracting memories from chat",
218
+ );
166
219
 
167
- const transcript = formatTranscript(messages);
220
+ const transcript = formatTranscript(messages, plan.from, consolidated);
168
221
  await runConsolidation(transcript, `chat session idle — ${room}`);
169
222
 
170
- // Mark as processed only on success
171
- processedCounts.set(sessionId, messages.length);
172
-
173
- // Evict oldest entries when over cap
174
- if (processedCounts.size > MAX_TRACKED) {
175
- const firstKey = processedCounts.keys().next().value;
176
- if (firstKey) processedCounts.delete(firstKey);
177
- }
223
+ // Advance the watermark only on success.
224
+ await Session.setConsolidatedCount(sessionId, messages.length);
178
225
  } catch (err) {
179
226
  log.error({ err, sessionId, room }, "consolidator: chat extraction failed");
180
227
  throw err;
@@ -4,6 +4,7 @@ import { fileURLToPath } from "url";
4
4
  import { getPaths } from "../utils/paths";
5
5
  import { getConfig, resetConfig } from "../utils/config";
6
6
  import { log } from "../utils/log";
7
+ import { asError, ignore } from "../utils/errors";
7
8
  import { isRunning, readPid, removePid, writePid } from "../utils/pid";
8
9
  import { ActiveEngine, Job } from "../db/models";
9
10
  import { runMigrations } from "../db/migrate";
@@ -192,7 +193,7 @@ export async function runDaemon(): Promise<void> {
192
193
  process.exit(1);
193
194
  });
194
195
  process.on("unhandledRejection", (reason) => {
195
- const err = reason instanceof Error ? reason : new Error(String(reason));
196
+ const err = asError(reason);
196
197
  log.fatal({ err }, "unhandled rejection — cleaning up");
197
198
  removePid();
198
199
  process.exit(1);
@@ -353,7 +354,7 @@ export async function runDaemon(): Promise<void> {
353
354
  log.info("received SIGHUP, reloading config");
354
355
  resetConfig();
355
356
  await reconcileChannels();
356
- await recomputeAllNextRuns().catch(() => {});
357
+ await ignore(recomputeAllNextRuns(), "recompute next runs after SIGHUP");
357
358
  });
358
359
 
359
360
  let shuttingDown = false;
@@ -12,6 +12,7 @@ import { consolidateSession } from "./consolidator";
12
12
  import { summarizeSession } from "./summarizer";
13
13
  import { loadConfig } from "../utils/config";
14
14
  import { log } from "../utils/log";
15
+ import { ignore } from "../utils/errors";
15
16
 
16
17
  type FinalizationTask = "consolidate" | "summarize";
17
18
 
@@ -132,11 +133,14 @@ async function processOne(sessionId: string, room: string, messageCount: number)
132
133
  log.error({ sessionId, room, messageCount, errors }, "finalizer: completed with task failures");
133
134
  }
134
135
  } catch (err) {
135
- await sql`
136
- UPDATE finalization_requests
137
- SET status = 'failed', updated_at = NOW()
138
- WHERE id = ${requestId}
139
- `.catch(() => {});
136
+ await ignore(
137
+ sql`
138
+ UPDATE finalization_requests
139
+ SET status = 'failed', updated_at = NOW()
140
+ WHERE id = ${requestId}
141
+ `,
142
+ "mark finalization request failed",
143
+ );
140
144
 
141
145
  log.error({ err, sessionId, room }, "finalizer: processing failed");
142
146
  }
@@ -13,6 +13,7 @@ import { buildJobPrompt } from "./job-prompt";
13
13
  import { getMcpServers, type McpSourceContext } from "../mcp";
14
14
  import { ActiveEngine } from "../db/models";
15
15
  import { log } from "../utils/log";
16
+ import { errMsg, ignore } from "../utils/errors";
16
17
  import { registerActiveHandle, unregisterActiveHandle } from "./active-handles";
17
18
  import { resolveChain, ChainCursor, describeEntry, type ChainEntry, type AgentSession, type AgentSessionContext, type FailoverScope } from "../agent";
18
19
 
@@ -105,7 +106,7 @@ async function runEntry(
105
106
  const session = await entry.backend.openSession({ ...sessionCtx, model: entry.model });
106
107
  return await consumeBackendRun(session, prompt, onActivity, activeRoom);
107
108
  } catch (err) {
108
- const message = err instanceof Error ? err.message : String(err);
109
+ const message = errMsg(err);
109
110
  log.warn({ entry: describeEntry(entry), err: message }, "backend failed to start");
110
111
  return { agentText: "", sessionId: "", error: message, failover: "provider" };
111
112
  }
@@ -178,7 +179,7 @@ export interface TaskOptions {
178
179
  */
179
180
  export async function runTask(opts: TaskOptions): Promise<RunnerOutput> {
180
181
  const room = `_system/${opts.name}`;
181
- await ActiveEngine.register(room, "system").catch(() => {});
182
+ await ignore(ActiveEngine.register(room, "system"), "register system active-engine");
182
183
  try {
183
184
  const systemPrompt = opts.systemPrompt || buildSystemPrompt("job");
184
185
  const output = await runOneShot({ systemPrompt, prompt: opts.prompt, cwd: homedir(), activeRoom: room });
@@ -189,7 +190,7 @@ export async function runTask(opts: TaskOptions): Promise<RunnerOutput> {
189
190
  }
190
191
  return output;
191
192
  } finally {
192
- await ActiveEngine.unregister(room).catch(() => {});
193
+ await ignore(ActiveEngine.unregister(room), "unregister active-engine");
193
194
  }
194
195
  }
195
196
 
@@ -207,7 +208,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
207
208
  const state: Record<string, JobState> = { ...readState() };
208
209
  state[job.name] = { lastRun: timestamp, status: "running", duration_ms: 0 };
209
210
  writeState(state);
210
- await ActiveEngine.register(room, "job").catch(() => {});
211
+ await ignore(ActiveEngine.register(room, "job"), "register job active-engine");
211
212
 
212
213
  try {
213
214
  let cwd = homedir();
@@ -300,7 +301,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
300
301
  return result;
301
302
  } catch (err) {
302
303
  const duration_ms = Math.round(performance.now() - startMs);
303
- const errorMsg = err instanceof Error ? err.message : String(err);
304
+ const errorMsg = errMsg(err);
304
305
 
305
306
  const result: JobResult = {
306
307
  job: job.name,
@@ -332,6 +333,6 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
332
333
 
333
334
  return result;
334
335
  } finally {
335
- await ActiveEngine.unregister(room).catch(() => {});
336
+ await ignore(ActiveEngine.unregister(room), "unregister active-engine");
336
337
  }
337
338
  }
@@ -85,8 +85,16 @@ async function tick(): Promise<void> {
85
85
  try {
86
86
  nextRun = computeNextRun(job.scheduleType, job.schedule, config.timezone, new Date());
87
87
  } catch (err) {
88
- log.error({ err, job: job.name, schedule: job.schedule }, "scheduler: invalid schedule, disabling job");
89
- await Job.update(job.name, { status: "disabled" }).catch(() => {});
88
+ log.error({ err, job: job.name, schedule: job.schedule }, "scheduler: invalid schedule");
89
+ try {
90
+ await Job.update(job.name, { status: "disabled" });
91
+ log.info({ job: job.name }, "scheduler: disabled job with invalid schedule");
92
+ } catch (updateErr) {
93
+ log.error(
94
+ { err: updateErr, job: job.name },
95
+ "scheduler: could not disable job with invalid schedule — it will keep failing every tick",
96
+ );
97
+ }
90
98
  continue;
91
99
  }
92
100
  await Job.markRun(job.name, nextRun).catch((err) => {
@@ -95,8 +103,15 @@ async function tick(): Promise<void> {
95
103
 
96
104
  // Auto-disable one-shot jobs after execution
97
105
  if (job.scheduleType === "once") {
98
- await Job.update(job.name, { status: "disabled" }).catch(() => {});
99
- log.info({ job: job.name }, "scheduler: one-shot job completed, auto-disabled");
106
+ try {
107
+ await Job.update(job.name, { status: "disabled" });
108
+ log.info({ job: job.name }, "scheduler: one-shot job completed, auto-disabled");
109
+ } catch (err) {
110
+ log.error(
111
+ { err, job: job.name },
112
+ "scheduler: one-shot job completed but could not be disabled — it will run again",
113
+ );
114
+ }
100
115
  }
101
116
  }
102
117
  }
@@ -63,10 +63,6 @@ export function scanSkills(): SkillInfo[] {
63
63
  return skills;
64
64
  }
65
65
 
66
- export function getSkillNames(): string[] {
67
- return scanSkills().map((s) => s.name);
68
- }
69
-
70
66
  export function getSkillsSummary(): string {
71
67
  const skills = scanSkills();
72
68
  if (skills.length === 0) return "";
@@ -0,0 +1,9 @@
1
+ import type postgres from "postgres";
2
+
3
+ export const name = "017_sessions_consolidated_count";
4
+
5
+ /** Watermark for the memory consolidator. Lives in the DB rather than in
6
+ * memory so a daemon restart does not re-consolidate every session again. */
7
+ export async function up(sql: postgres.Sql): Promise<void> {
8
+ await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS consolidated_count INTEGER NOT NULL DEFAULT 0`;
9
+ }
@@ -26,13 +26,6 @@ export async function unregister(room: string): Promise<void> {
26
26
  await sql`DELETE FROM active_engines WHERE room = ${room}`;
27
27
  }
28
28
 
29
- export async function clearStale(
30
- maxAgeMs: number = 5 * 60 * 1000,
31
- ): Promise<void> {
32
- const sql = getSql();
33
- await sql`DELETE FROM active_engines WHERE last_ping < NOW() - ${maxAgeMs / 1000}::int * interval '1 second'`;
34
- }
35
-
36
29
  export async function clearAll(): Promise<void> {
37
30
  const sql = getSql();
38
31
  await sql`DELETE FROM active_engines`;
@@ -1,4 +1,5 @@
1
1
  import { getSql } from "../connection";
2
+ import { errMsg } from "../../utils/errors";
2
3
  import { CronExpressionParser } from "cron-parser";
3
4
  import { parseDuration } from "../../utils/duration";
4
5
  import { computeInitialNextRun } from "../../utils/schedule";
@@ -13,14 +14,14 @@ function validateSchedule(schedule: string, scheduleType: ScheduleType): void {
13
14
  try {
14
15
  CronExpressionParser.parse(schedule);
15
16
  } catch (err) {
16
- throw new Error(`Invalid cron expression "${schedule}": ${err instanceof Error ? err.message : err}`);
17
+ throw new Error(`Invalid cron expression "${schedule}": ${errMsg(err)}`);
17
18
  }
18
19
  break;
19
20
  case "interval":
20
21
  try {
21
22
  parseDuration(schedule);
22
23
  } catch (err) {
23
- throw new Error(`Invalid interval "${schedule}": ${err instanceof Error ? err.message : err}`);
24
+ throw new Error(`Invalid interval "${schedule}": ${errMsg(err)}`);
24
25
  }
25
26
  break;
26
27
  case "once": {
@@ -46,6 +46,16 @@ export async function getRecent(limit = 20, room?: string): Promise<RecentMessag
46
46
  }));
47
47
  }
48
48
 
49
+ /** When the room last saw any message. Null for a room with no history. */
50
+ export async function getLastAt(room: string): Promise<Date | null> {
51
+ const sql = getSql();
52
+ const rows = await sql`
53
+ SELECT created_at FROM messages WHERE room = ${room}
54
+ ORDER BY created_at DESC LIMIT 1
55
+ `;
56
+ return rows.length > 0 ? new Date(rows[0].created_at) : null;
57
+ }
58
+
49
59
  export async function search(query: string, limit = 20, room?: string): Promise<SearchResult[]> {
50
60
  const sql = getSql();
51
61
  const pattern = `%${query}%`;
@@ -122,24 +122,62 @@ export async function getRecentSummaries(
122
122
  }));
123
123
  }
124
124
 
125
+ export interface UsageTotals {
126
+ inputTokens: number;
127
+ outputTokens: number;
128
+ cacheReadTokens: number;
129
+ cacheCreationTokens: number;
130
+ models: string[];
131
+ providers: string[];
132
+ }
133
+
134
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
135
+ const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
136
+
137
+ /**
138
+ * Fold a result's per-model usage into totals. `provider` matters because a
139
+ * chain that fails over makes the model name alone ambiguous about who served
140
+ * the turn.
141
+ */
142
+ export function summarizeModelUsage(modelUsage: unknown): UsageTotals {
143
+ const totals: UsageTotals = {
144
+ inputTokens: 0,
145
+ outputTokens: 0,
146
+ cacheReadTokens: 0,
147
+ cacheCreationTokens: 0,
148
+ models: [],
149
+ providers: [],
150
+ };
151
+ if (!modelUsage || typeof modelUsage !== "object") return totals;
152
+
153
+ const models = new Set<string>();
154
+ const providers = new Set<string>();
155
+ for (const [key, raw] of Object.entries(modelUsage as Record<string, unknown>)) {
156
+ const usage = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
157
+ models.add(str(usage.canonicalModel) ?? key);
158
+ const provider = str(usage.provider);
159
+ if (provider) providers.add(provider);
160
+ totals.inputTokens += num(usage.inputTokens);
161
+ totals.outputTokens += num(usage.outputTokens);
162
+ totals.cacheReadTokens += num(usage.cacheReadInputTokens);
163
+ totals.cacheCreationTokens += num(usage.cacheCreationInputTokens);
164
+ }
165
+ totals.models = [...models];
166
+ totals.providers = [...providers];
167
+ return totals;
168
+ }
169
+
125
170
  export async function accumulateMetadata(id: string, resultMeta: Record<string, unknown>): Promise<void> {
126
171
  const sql = getSql();
127
172
 
128
- const modelUsage = resultMeta.model_usage as Record<string, Record<string, number>> | undefined;
129
- let inputTokens = 0;
130
- let outputTokens = 0;
131
- let cacheReadTokens = 0;
132
- let cacheCreationTokens = 0;
133
- const newModels: string[] = [];
134
- if (modelUsage) {
135
- for (const [model, usage] of Object.entries(modelUsage)) {
136
- newModels.push(model);
137
- inputTokens += usage.inputTokens || 0;
138
- outputTokens += usage.outputTokens || 0;
139
- cacheReadTokens += usage.cacheReadInputTokens || 0;
140
- cacheCreationTokens += usage.cacheCreationInputTokens || 0;
141
- }
142
- }
173
+ const {
174
+ inputTokens,
175
+ outputTokens,
176
+ cacheReadTokens,
177
+ cacheCreationTokens,
178
+ models: newModels,
179
+ providers: newProviders,
180
+ } = summarizeModelUsage(resultMeta.model_usage);
143
181
 
144
182
  // Bind deltas as jsonb via sql.json — pre-stringifying would store a
145
183
  // double-encoded string scalar, making every `->>` extraction return NULL
@@ -155,9 +193,11 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
155
193
  total_cache_creation_tokens: cacheCreationTokens,
156
194
  message_count: 1,
157
195
  models_used: newModels,
196
+ providers_used: newProviders,
158
197
  channel: (resultMeta.channel as string) || null,
159
198
  });
160
199
  const modelsDelta = sql.json(newModels);
200
+ const providersDelta = sql.json(newProviders);
161
201
 
162
202
  // Atomic accumulate — no read-then-write race
163
203
  await sql`
@@ -173,6 +213,8 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
173
213
  'message_count', COALESCE((metadata->>'message_count')::int, 0) + 1,
174
214
  'models_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
175
215
  FROM jsonb_array_elements(COALESCE(metadata->'models_used', '[]'::jsonb) || ${modelsDelta}) AS e),
216
+ 'providers_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
217
+ FROM jsonb_array_elements(COALESCE(metadata->'providers_used', '[]'::jsonb) || ${providersDelta}) AS e),
176
218
  'channel', COALESCE(metadata->>'channel', ${(resultMeta.channel as string) || null})
177
219
  )
178
220
  WHERE id = ${id}
@@ -192,3 +234,15 @@ export async function getLatestRoomIndex(prefix: string): Promise<number> {
192
234
  }
193
235
  return max;
194
236
  }
237
+
238
+ /** How many messages of this session the consolidator has already read. */
239
+ export async function getConsolidatedCount(sessionId: string): Promise<number> {
240
+ const sql = getSql();
241
+ const rows = await sql`SELECT consolidated_count FROM sessions WHERE id = ${sessionId}`;
242
+ return rows.length > 0 ? Number(rows[0].consolidated_count ?? 0) : 0;
243
+ }
244
+
245
+ export async function setConsolidatedCount(sessionId: string, count: number): Promise<void> {
246
+ const sql = getSql();
247
+ await sql`UPDATE sessions SET consolidated_count = ${count} WHERE id = ${sessionId}`;
248
+ }
@@ -1,4 +1,5 @@
1
1
  import { appendFileSync } from "fs";
2
+ import { errMsg } from "../../utils/errors";
2
3
  import { join } from "path";
3
4
  import { getPaths } from "../../utils/paths";
4
5
  import { scanAgents } from "../../core/agents";
@@ -58,6 +59,6 @@ export async function placeCall(args: {
58
59
  });
59
60
  return `Call placed. callSid=${result.callSid} status=${result.status}. Transcript will land in messages once the call completes.`;
60
61
  } catch (err) {
61
- return `place_call failed: ${err instanceof Error ? err.message : String(err)}`;
62
+ return `place_call failed: ${errMsg(err)}`;
62
63
  }
63
64
  }