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.
- package/package.json +4 -4
- package/src/agent/backends/claude-normalize.ts +41 -1
- package/src/agent/backends/claude.ts +2 -1
- package/src/agent/backends/codex-normalize.ts +20 -6
- package/src/agent/backends/codex.ts +3 -2
- package/src/agent/failure.ts +3 -1
- package/src/agent/mcp-endpoint.ts +3 -2
- package/src/channels/common/chat-session.ts +60 -0
- package/src/channels/phone/consult.ts +2 -1
- package/src/channels/phone/index.ts +5 -3
- package/src/channels/phone/relay.ts +15 -5
- package/src/channels/slack.ts +24 -34
- package/src/channels/sms.ts +16 -36
- package/src/channels/telegram.ts +22 -34
- package/src/channels/twilio/media-cache.ts +4 -3
- package/src/channels/twilio/rest.ts +0 -31
- package/src/channels/twilio/server.ts +0 -5
- package/src/channels/twilio/shared.ts +36 -0
- package/src/channels/whatsapp.ts +24 -58
- package/src/chat/engine.ts +17 -8
- package/src/chat/gap-marker.ts +63 -0
- package/src/chat/repl.ts +5 -5
- package/src/cli/config.ts +71 -0
- package/src/cli/index.ts +23 -288
- package/src/cli/job.ts +1 -2
- package/src/cli/logs.ts +55 -0
- package/src/cli/run.ts +74 -0
- package/src/cli/skills.ts +13 -0
- package/src/cli/status.ts +0 -1
- package/src/cli/test.ts +32 -0
- package/src/cli/update.ts +79 -0
- package/src/commands/backup.ts +1 -2
- package/src/commands/init.ts +1 -2
- package/src/core/alive.ts +4 -3
- package/src/core/consolidator.ts +78 -31
- package/src/core/daemon.ts +3 -2
- package/src/core/finalizer.ts +9 -5
- package/src/core/runner.ts +7 -6
- package/src/core/scheduler.ts +19 -4
- package/src/core/skills.ts +0 -4
- package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
- package/src/db/models/active_engine.ts +0 -7
- package/src/db/models/job.ts +3 -2
- package/src/db/models/message.ts +10 -0
- package/src/db/models/session.ts +69 -15
- package/src/mcp/tools/misc.ts +2 -1
- package/src/mcp/tools/send.ts +5 -4
- 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
|
+
}
|
package/src/commands/backup.ts
CHANGED
|
@@ -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;
|
package/src/commands/init.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as readline from "readline";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync
|
|
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: ${
|
|
164
|
+
report: `Recovery agent failed to run: ${errMsg(err)}`,
|
|
164
165
|
};
|
|
165
166
|
}
|
|
166
167
|
}
|
package/src/core/consolidator.ts
CHANGED
|
@@ -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
|
|
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
|
|
33
|
-
function formatTranscript(messages: SessionMessage[]): string {
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
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
|
-
//
|
|
171
|
-
|
|
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;
|
package/src/core/daemon.ts
CHANGED
|
@@ -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 =
|
|
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()
|
|
357
|
+
await ignore(recomputeAllNextRuns(), "recompute next runs after SIGHUP");
|
|
357
358
|
});
|
|
358
359
|
|
|
359
360
|
let shuttingDown = false;
|
package/src/core/finalizer.ts
CHANGED
|
@@ -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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
}
|
package/src/core/runner.ts
CHANGED
|
@@ -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 =
|
|
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")
|
|
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)
|
|
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")
|
|
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 =
|
|
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)
|
|
336
|
+
await ignore(ActiveEngine.unregister(room), "unregister active-engine");
|
|
336
337
|
}
|
|
337
338
|
}
|
package/src/core/scheduler.ts
CHANGED
|
@@ -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
|
|
89
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
}
|
package/src/core/skills.ts
CHANGED
|
@@ -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`;
|
package/src/db/models/job.ts
CHANGED
|
@@ -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
|
|
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
|
|
24
|
+
throw new Error(`Invalid interval "${schedule}": ${errMsg(err)}`);
|
|
24
25
|
}
|
|
25
26
|
break;
|
|
26
27
|
case "once": {
|
package/src/db/models/message.ts
CHANGED
|
@@ -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}%`;
|
package/src/db/models/session.ts
CHANGED
|
@@ -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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
package/src/mcp/tools/misc.ts
CHANGED
|
@@ -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: ${
|
|
62
|
+
return `place_call failed: ${errMsg(err)}`;
|
|
62
63
|
}
|
|
63
64
|
}
|