niahere 0.2.62 → 0.2.64
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 +7 -3
- package/src/chat/employee-prompt.ts +75 -0
- package/src/chat/engine.ts +40 -7
- package/src/chat/identity.ts +8 -1
- package/src/chat/repl.ts +52 -25
- package/src/cli/employee-add.ts +124 -0
- package/src/cli/employee.ts +167 -0
- package/src/cli/index.ts +43 -77
- package/src/cli/job.ts +36 -93
- package/src/cli/status.ts +18 -9
- package/src/commands/backup.ts +3 -7
- package/src/core/agents.ts +8 -20
- package/src/core/consolidator.ts +14 -28
- package/src/core/daemon.ts +4 -41
- package/src/core/employees.ts +116 -0
- package/src/core/finalizer.ts +31 -3
- package/src/core/health.ts +5 -17
- package/src/core/runner.ts +15 -9
- package/src/core/scheduler.ts +12 -49
- package/src/core/skills.ts +6 -12
- package/src/core/summarizer.ts +7 -21
- package/src/db/connection.ts +0 -11
- package/src/db/migrations/015_jobs_employee.ts +7 -0
- package/src/db/models/job.ts +34 -28
- package/src/db/with-db.ts +11 -0
- package/src/mcp/server.ts +15 -2
- package/src/mcp/tools.ts +13 -2
- package/src/prompts/environment.md +44 -41
- package/src/types/employee.ts +14 -0
- package/src/types/engine.ts +9 -2
- package/src/types/index.ts +1 -0
- package/src/types/job.ts +1 -0
- package/src/types/paths.ts +1 -0
- package/src/utils/paths.ts +1 -0
- package/src/utils/pid.ts +44 -0
- package/src/utils/schedule.ts +39 -0
package/src/commands/backup.ts
CHANGED
|
@@ -49,6 +49,7 @@ export async function createBackup(silent = false): Promise<string> {
|
|
|
49
49
|
if (existsSync(join(home, "self"))) includes.push("self");
|
|
50
50
|
if (existsSync(join(home, "agents"))) includes.push("agents");
|
|
51
51
|
if (existsSync(join(home, "skills"))) includes.push("skills");
|
|
52
|
+
if (existsSync(join(home, "employees"))) includes.push("employees");
|
|
52
53
|
|
|
53
54
|
// Database dump
|
|
54
55
|
const config = getConfig();
|
|
@@ -65,10 +66,7 @@ export async function createBackup(silent = false): Promise<string> {
|
|
|
65
66
|
if (url.port) pgArgs.push("-p", url.port);
|
|
66
67
|
if (url.username) pgArgs.push("-U", decodeURIComponent(url.username));
|
|
67
68
|
if (dbName) pgArgs.push("-d", dbName);
|
|
68
|
-
const pgEnv: Record<string, string> = { ...process.env } as Record<
|
|
69
|
-
string,
|
|
70
|
-
string
|
|
71
|
-
>;
|
|
69
|
+
const pgEnv: Record<string, string> = { ...process.env } as Record<string, string>;
|
|
72
70
|
if (url.password) pgEnv.PGPASSWORD = decodeURIComponent(url.password);
|
|
73
71
|
const sslmode = url.searchParams.get("sslmode");
|
|
74
72
|
if (sslmode) pgEnv.PGSSLMODE = sslmode;
|
|
@@ -87,9 +85,7 @@ export async function createBackup(silent = false): Promise<string> {
|
|
|
87
85
|
dbDumped = true;
|
|
88
86
|
} else if (!silent) {
|
|
89
87
|
const stderr = await new Response(pg.stderr).text();
|
|
90
|
-
console.log(
|
|
91
|
-
` ⚠ db dump skipped: ${stderr.trim() || `exit ${exitCode}`}`,
|
|
92
|
-
);
|
|
88
|
+
console.log(` ⚠ db dump skipped: ${stderr.trim() || `exit ${exitCode}`}`);
|
|
93
89
|
}
|
|
94
90
|
}
|
|
95
91
|
|
package/src/core/agents.ts
CHANGED
|
@@ -34,7 +34,8 @@ export function scanAgents(): AgentInfo[] {
|
|
|
34
34
|
for (const { dir, source } of getAgentDirs()) {
|
|
35
35
|
if (!existsSync(dir)) continue;
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
38
|
+
for (const entry of entries) {
|
|
38
39
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
39
40
|
|
|
40
41
|
const agentFile = join(dir, entry.name, "AGENT.md");
|
|
@@ -48,14 +49,10 @@ export function scanAgents(): AgentInfo[] {
|
|
|
48
49
|
try {
|
|
49
50
|
meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
|
|
50
51
|
} catch (err) {
|
|
51
|
-
log.warn(
|
|
52
|
-
{ err, agent: entry.name, path: agentFile },
|
|
53
|
-
"failed to parse agent metadata, skipping",
|
|
54
|
-
);
|
|
52
|
+
log.warn({ err, agent: entry.name, path: agentFile }, "failed to parse agent metadata, skipping");
|
|
55
53
|
continue;
|
|
56
54
|
}
|
|
57
|
-
const name =
|
|
58
|
-
(typeof meta.name === "string" ? meta.name : "") || entry.name;
|
|
55
|
+
const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
|
|
59
56
|
|
|
60
57
|
const key = name.toLowerCase();
|
|
61
58
|
if (seen.has(key)) continue;
|
|
@@ -65,8 +62,7 @@ export function scanAgents(): AgentInfo[] {
|
|
|
65
62
|
|
|
66
63
|
agents.push({
|
|
67
64
|
name,
|
|
68
|
-
description:
|
|
69
|
-
typeof meta.description === "string" ? meta.description : "",
|
|
65
|
+
description: typeof meta.description === "string" ? meta.description : "",
|
|
70
66
|
body,
|
|
71
67
|
model: typeof meta.model === "string" ? meta.model : undefined,
|
|
72
68
|
source,
|
|
@@ -80,21 +76,13 @@ export function scanAgents(): AgentInfo[] {
|
|
|
80
76
|
export function getAgentsSummary(): string {
|
|
81
77
|
const agents = scanAgents();
|
|
82
78
|
if (agents.length === 0) return "";
|
|
83
|
-
const lines = agents.map((a) =>
|
|
84
|
-
a.description ? `- @${a.name}: ${a.description}` : `- @${a.name}`,
|
|
85
|
-
);
|
|
79
|
+
const lines = agents.map((a) => (a.description ? `- @${a.name}: ${a.description}` : `- @${a.name}`));
|
|
86
80
|
return `Available agents:\n${lines.join("\n")}`;
|
|
87
81
|
}
|
|
88
82
|
|
|
89
|
-
export function getAgentDefinitions(): Record<
|
|
90
|
-
string,
|
|
91
|
-
{ description: string; prompt: string; model?: string }
|
|
92
|
-
> {
|
|
83
|
+
export function getAgentDefinitions(): Record<string, { description: string; prompt: string; model?: string }> {
|
|
93
84
|
const agents = scanAgents();
|
|
94
|
-
const defs: Record<
|
|
95
|
-
string,
|
|
96
|
-
{ description: string; prompt: string; model?: string }
|
|
97
|
-
> = {};
|
|
85
|
+
const defs: Record<string, { description: string; prompt: string; model?: string }> = {};
|
|
98
86
|
|
|
99
87
|
for (const agent of agents) {
|
|
100
88
|
defs[agent.name] = {
|
package/src/core/consolidator.ts
CHANGED
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory consolidator — stage 1 of
|
|
2
|
+
* Memory consolidator — stage 1 of the two-stage memory pipeline.
|
|
3
3
|
*
|
|
4
|
-
* After a chat session goes idle,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* After a chat session goes idle, reflects on the transcript and appends
|
|
5
|
+
* CANDIDATE memories to ~/.niahere/self/staging.md. The nightly
|
|
6
|
+
* memory-promoter job handles promotion from staging to memory.md/rules.md.
|
|
7
|
+
* The write-path restriction is enforced by the consolidator prompt, not
|
|
8
|
+
* by tool sandboxing.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* chat idle → consolidator → staging.md (candidate log, TTL 14d)
|
|
12
|
-
* ↓ nightly promoter (3am)
|
|
13
|
-
* ↓ - count >= 2 required
|
|
14
|
-
* ↓ - durability review
|
|
15
|
-
* memory.md / rules.md
|
|
16
|
-
*
|
|
17
|
-
* Jobs do NOT flow through this path — job-local learnings live in each
|
|
18
|
-
* job's state.md (see runner.ts:buildWorkingMemory). Routing job output
|
|
19
|
-
* into global persona memory caused layer violations (transient incidents
|
|
20
|
-
* promoted to durable facts).
|
|
21
|
-
*
|
|
22
|
-
* The consolidator uses the same agent loop as cron jobs — full Nia system
|
|
23
|
-
* prompt, full tool access. The write-path restriction is enforced by the
|
|
24
|
-
* prompt (the agent is told to only edit staging.md), not by tool sandboxing.
|
|
10
|
+
* See AGENTS.md > "Two-stage memory" for the full architecture.
|
|
25
11
|
*/
|
|
26
12
|
|
|
27
13
|
import { Message } from "../db/models";
|
|
@@ -138,12 +124,16 @@ Report a one-line summary of what you did: "staged N new / reinforced M /
|
|
|
138
124
|
skipped (trivial session)". No preamble.`;
|
|
139
125
|
}
|
|
140
126
|
|
|
141
|
-
/** Run the consolidation agent loop. */
|
|
142
127
|
async function runConsolidation(transcript: string, source: string): Promise<void> {
|
|
143
|
-
await runTask({
|
|
128
|
+
const output = await runTask({
|
|
144
129
|
name: "consolidator",
|
|
145
130
|
prompt: buildConsolidationPrompt(transcript, source),
|
|
146
131
|
});
|
|
132
|
+
// runTask returns {error} on failure instead of throwing; escalate so
|
|
133
|
+
// consolidateSession doesn't mark the session processed on a failed run.
|
|
134
|
+
if (output.error) {
|
|
135
|
+
throw new Error(`consolidator task failed: ${output.error}`);
|
|
136
|
+
}
|
|
147
137
|
}
|
|
148
138
|
|
|
149
139
|
/**
|
|
@@ -178,12 +168,8 @@ export async function consolidateSession(sessionId: string, room: string): Promi
|
|
|
178
168
|
}
|
|
179
169
|
} catch (err) {
|
|
180
170
|
log.error({ err, sessionId, room }, "consolidator: chat extraction failed");
|
|
171
|
+
throw err;
|
|
181
172
|
} finally {
|
|
182
173
|
inFlight.delete(sessionId);
|
|
183
174
|
}
|
|
184
175
|
}
|
|
185
|
-
|
|
186
|
-
// Job runs no longer flow through the global memory consolidator. Each job
|
|
187
|
-
// maintains its own working memory in state.md (see buildWorkingMemory() in
|
|
188
|
-
// runner.ts). This separation prevents transient job-local incidents from
|
|
189
|
-
// being promoted to durable persona memory.
|
package/src/core/daemon.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync,
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "fs";
|
|
2
2
|
import { dirname, resolve as pathResolve } from "path";
|
|
3
3
|
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 { isRunning, readPid, removePid, writePid } from "../utils/pid";
|
|
7
8
|
import { ActiveEngine, Job } from "../db/models";
|
|
8
9
|
import { runMigrations } from "../db/migrate";
|
|
9
10
|
import { closeDb, getSql } from "../db/connection";
|
|
@@ -15,45 +16,7 @@ import { createNiaMcpServer } from "../mcp/server";
|
|
|
15
16
|
import { setMcpFactory } from "../mcp";
|
|
16
17
|
import { processPending, cleanupOldRequests } from "./finalizer";
|
|
17
18
|
|
|
18
|
-
export
|
|
19
|
-
const { pid: pidPath } = getPaths();
|
|
20
|
-
mkdirSync(dirname(pidPath), { recursive: true });
|
|
21
|
-
writeFileSync(pidPath, String(pid));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function readPid(): number | null {
|
|
25
|
-
const { pid: pidPath } = getPaths();
|
|
26
|
-
if (!existsSync(pidPath)) return null;
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
return parseInt(readFileSync(pidPath, "utf8").trim(), 10);
|
|
30
|
-
} catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function removePid(): void {
|
|
36
|
-
const { pid: pidPath } = getPaths();
|
|
37
|
-
try {
|
|
38
|
-
unlinkSync(pidPath);
|
|
39
|
-
} catch {
|
|
40
|
-
// Already gone
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function isRunning(): boolean {
|
|
45
|
-
const pid = readPid();
|
|
46
|
-
if (pid === null) return false;
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
process.kill(pid, 0);
|
|
50
|
-
return true;
|
|
51
|
-
} catch {
|
|
52
|
-
log.warn({ stalePid: pid }, "removing stale pid file (process not running)");
|
|
53
|
-
removePid();
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
19
|
+
export { isRunning, readPid, removePid, writePid };
|
|
57
20
|
|
|
58
21
|
export function startDaemon(): number {
|
|
59
22
|
const { daemonLog } = getPaths();
|
|
@@ -125,7 +88,7 @@ function waitForExit(timeoutMs: number): void {
|
|
|
125
88
|
/** Return PIDs of running daemon processes (excluding ourselves). */
|
|
126
89
|
export function findDaemonPids(): number[] {
|
|
127
90
|
try {
|
|
128
|
-
const result = Bun.spawnSync(["pgrep", "-f", "src/cli\\.ts run$"]);
|
|
91
|
+
const result = Bun.spawnSync(["pgrep", "-f", "src/cli/index\\.ts run$"]);
|
|
129
92
|
const stdout = new TextDecoder().decode(result.stdout).trim();
|
|
130
93
|
if (!stdout) return [];
|
|
131
94
|
return stdout
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { getNiaHome } from "../utils/paths";
|
|
5
|
+
import { log } from "../utils/log";
|
|
6
|
+
import type { EmployeeInfo } from "../types/employee";
|
|
7
|
+
|
|
8
|
+
function getEmployeesDir(): string {
|
|
9
|
+
return join(getNiaHome(), "employees");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function scanEmployees(): EmployeeInfo[] {
|
|
13
|
+
const employees: EmployeeInfo[] = [];
|
|
14
|
+
const dir = getEmployeesDir();
|
|
15
|
+
if (!existsSync(dir)) return employees;
|
|
16
|
+
|
|
17
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
21
|
+
|
|
22
|
+
const empFile = join(dir, entry.name, "EMPLOYEE.md");
|
|
23
|
+
if (!existsSync(empFile)) continue;
|
|
24
|
+
|
|
25
|
+
const content = readFileSync(empFile, "utf8");
|
|
26
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
27
|
+
if (!fmMatch) continue;
|
|
28
|
+
|
|
29
|
+
let meta: Record<string, unknown> = {};
|
|
30
|
+
try {
|
|
31
|
+
meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
|
|
32
|
+
} catch (err) {
|
|
33
|
+
log.warn({ err, employee: entry.name, path: empFile }, "failed to parse employee metadata, skipping");
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
|
|
38
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
|
|
39
|
+
|
|
40
|
+
employees.push({
|
|
41
|
+
name,
|
|
42
|
+
dirName: entry.name,
|
|
43
|
+
project: typeof meta.project === "string" ? meta.project : "",
|
|
44
|
+
repo: typeof meta.repo === "string" ? meta.repo : "",
|
|
45
|
+
role: typeof meta.role === "string" ? meta.role : "Employee",
|
|
46
|
+
model: typeof meta.model === "string" ? meta.model : undefined,
|
|
47
|
+
status:
|
|
48
|
+
meta.status === "onboarding" || meta.status === "active" || meta.status === "paused"
|
|
49
|
+
? meta.status
|
|
50
|
+
: "onboarding",
|
|
51
|
+
maxSubEmployees: typeof meta.maxSubEmployees === "number" ? meta.maxSubEmployees : 3,
|
|
52
|
+
body,
|
|
53
|
+
created: typeof meta.created === "string" ? meta.created : new Date().toISOString().slice(0, 10),
|
|
54
|
+
parent: typeof meta.parent === "string" ? meta.parent : undefined,
|
|
55
|
+
source: "nia",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return employees;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getEmployee(name: string): EmployeeInfo | undefined {
|
|
63
|
+
return scanEmployees().find((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getEmployeeDir(name: string): string {
|
|
67
|
+
// Look up actual directory — name in frontmatter may differ from dir name
|
|
68
|
+
const emp = scanEmployees().find((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
69
|
+
if (emp) return join(getEmployeesDir(), emp.dirName);
|
|
70
|
+
// Fallback for new employees being created (not yet on disk)
|
|
71
|
+
return join(getEmployeesDir(), name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getEmployeesSummary(): string {
|
|
75
|
+
const employees = scanEmployees();
|
|
76
|
+
if (employees.length === 0) return "";
|
|
77
|
+
const lines = employees.map((e) => `- @${e.name}: ${e.role} — ${e.project || "(no project)"} [${e.status}]`);
|
|
78
|
+
return `Available employees:\n${lines.join("\n")}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listEmployeesForMcp(): string {
|
|
82
|
+
const employees = scanEmployees();
|
|
83
|
+
if (employees.length === 0) return "No employees found.";
|
|
84
|
+
return JSON.stringify(
|
|
85
|
+
employees.map((e) => ({
|
|
86
|
+
name: e.name,
|
|
87
|
+
role: e.role,
|
|
88
|
+
project: e.project,
|
|
89
|
+
repo: e.repo,
|
|
90
|
+
status: e.status,
|
|
91
|
+
model: e.model,
|
|
92
|
+
})),
|
|
93
|
+
null,
|
|
94
|
+
2,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Injected into employee prompt only when status=onboarding. */
|
|
99
|
+
export const ONBOARDING_INSTRUCTIONS = `## Onboarding
|
|
100
|
+
|
|
101
|
+
You are in onboarding status. Be proactive — don't wait for the user to drive. You're a co-founder getting up to speed, not an assistant being briefed.
|
|
102
|
+
|
|
103
|
+
IMPORTANT: One thing at a time. Each message should focus on ONE step. Don't dump all steps on the user at once. Move to the next step only after the current one is resolved.
|
|
104
|
+
|
|
105
|
+
During the brief, don't just record — challenge. Ask follow-up questions. If the vision sounds vague, say so. If the goals are unrealistic, push back. If something sounds like it matters more than what the user is focused on, flag it.
|
|
106
|
+
|
|
107
|
+
### Steps (do these in order, one per message)
|
|
108
|
+
1. **Identity** — If your name is a placeholder (starts with "new-employee"), suggest 3-4 real names and ask the user to pick. Update the name field in your EMPLOYEE.md frontmatter. Do NOT rename the directory — the system resolves it from frontmatter.
|
|
109
|
+
2. **Project & Repo** — If project or repo are empty, ask what you'll be working on. Get the repo path. Update your EMPLOYEE.md.
|
|
110
|
+
3. **Brief** — Ask the user about the project: goals, what's working, what's not, their vision. Save to onboarding/brief.md.
|
|
111
|
+
4. **Self-Discovery** — Explore the repo autonomously. Read code, README, recent commits, deployment config. Save findings to onboarding/discovery.md. Report back to user for corrections.
|
|
112
|
+
5. **Initial Plan** — Propose top 3-5 priorities with first actions for each. Save to onboarding/plan.md. Get user approval.
|
|
113
|
+
|
|
114
|
+
After all steps are done, update your EMPLOYEE.md status from "onboarding" to "active".
|
|
115
|
+
|
|
116
|
+
Skip any step where the info is already filled in.`;
|
package/src/core/finalizer.ts
CHANGED
|
@@ -78,15 +78,32 @@ async function processOne(sessionId: string, room: string, messageCount: number)
|
|
|
78
78
|
const requestId = claimed[0].id;
|
|
79
79
|
|
|
80
80
|
try {
|
|
81
|
-
await Promise.allSettled([
|
|
81
|
+
const [consolidateResult, summarizeResult] = await Promise.allSettled([
|
|
82
|
+
consolidateSession(sessionId, room),
|
|
83
|
+
summarizeSession(sessionId, room),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const errors: string[] = [];
|
|
87
|
+
if (consolidateResult.status === "rejected") {
|
|
88
|
+
errors.push(`consolidate: ${formatRejection(consolidateResult.reason)}`);
|
|
89
|
+
}
|
|
90
|
+
if (summarizeResult.status === "rejected") {
|
|
91
|
+
errors.push(`summarize: ${formatRejection(summarizeResult.reason)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const finalStatus = errors.length === 0 ? "done" : "failed";
|
|
82
95
|
|
|
83
96
|
await sql`
|
|
84
97
|
UPDATE finalization_requests
|
|
85
|
-
SET status =
|
|
98
|
+
SET status = ${finalStatus}, updated_at = NOW()
|
|
86
99
|
WHERE id = ${requestId}
|
|
87
100
|
`;
|
|
88
101
|
|
|
89
|
-
|
|
102
|
+
if (errors.length === 0) {
|
|
103
|
+
log.info({ sessionId, room, messageCount }, "finalizer: completed");
|
|
104
|
+
} else {
|
|
105
|
+
log.error({ sessionId, room, messageCount, errors }, "finalizer: completed with task failures");
|
|
106
|
+
}
|
|
90
107
|
} catch (err) {
|
|
91
108
|
await sql`
|
|
92
109
|
UPDATE finalization_requests
|
|
@@ -98,6 +115,17 @@ async function processOne(sessionId: string, room: string, messageCount: number)
|
|
|
98
115
|
}
|
|
99
116
|
}
|
|
100
117
|
|
|
118
|
+
/** Normalize a Promise rejection reason into a loggable string. */
|
|
119
|
+
function formatRejection(reason: unknown): string {
|
|
120
|
+
if (reason instanceof Error) return reason.message;
|
|
121
|
+
if (typeof reason === "string") return reason;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.stringify(reason);
|
|
124
|
+
} catch {
|
|
125
|
+
return String(reason);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
/** Drain all pending finalization requests. Called by daemon on startup and on NOTIFY. */
|
|
102
130
|
export async function processPending(): Promise<void> {
|
|
103
131
|
const sql = getSql();
|
package/src/core/health.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "fs";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { getConfig, readRawConfig } from "../utils/config";
|
|
4
4
|
import { getPaths } from "../utils/paths";
|
|
5
|
-
import { isRunning, readPid } from "
|
|
5
|
+
import { isRunning, readPid } from "../utils/pid";
|
|
6
6
|
import { errMsg } from "../utils/errors";
|
|
7
7
|
import { localTime } from "../utils/time";
|
|
8
8
|
import { withRetry } from "../utils/retry";
|
|
@@ -123,11 +123,7 @@ export async function runHealthChecks(): Promise<Check[]> {
|
|
|
123
123
|
}),
|
|
124
124
|
);
|
|
125
125
|
const data = (await resp.json()) as { ok: boolean; error?: string };
|
|
126
|
-
results.push(
|
|
127
|
-
data.ok
|
|
128
|
-
? "slack: connected"
|
|
129
|
-
: `slack: ${data.error || "auth failed"}`,
|
|
130
|
-
);
|
|
126
|
+
results.push(data.ok ? "slack: connected" : `slack: ${data.error || "auth failed"}`);
|
|
131
127
|
if (!data.ok)
|
|
132
128
|
checks.push({
|
|
133
129
|
name: "slack",
|
|
@@ -159,10 +155,7 @@ export async function runHealthChecks(): Promise<Check[]> {
|
|
|
159
155
|
// API keys
|
|
160
156
|
const geminiKey = config.gemini_api_key;
|
|
161
157
|
const rawConfig = readRawConfig();
|
|
162
|
-
const openaiKey =
|
|
163
|
-
typeof rawConfig.openai_api_key === "string"
|
|
164
|
-
? rawConfig.openai_api_key
|
|
165
|
-
: null;
|
|
158
|
+
const openaiKey = typeof rawConfig.openai_api_key === "string" ? rawConfig.openai_api_key : null;
|
|
166
159
|
const apiKeys: string[] = [];
|
|
167
160
|
if (geminiKey) apiKeys.push("gemini");
|
|
168
161
|
if (openaiKey) apiKeys.push("openai");
|
|
@@ -174,16 +167,11 @@ export async function runHealthChecks(): Promise<Check[]> {
|
|
|
174
167
|
|
|
175
168
|
// Persona files
|
|
176
169
|
const personaFiles = ["identity.md", "owner.md", "soul.md"];
|
|
177
|
-
const missing = personaFiles.filter(
|
|
178
|
-
(f) => !existsSync(join(paths.selfDir, f)),
|
|
179
|
-
);
|
|
170
|
+
const missing = personaFiles.filter((f) => !existsSync(join(paths.selfDir, f)));
|
|
180
171
|
checks.push({
|
|
181
172
|
name: "persona",
|
|
182
173
|
status: missing.length === 0 ? "ok" : "warn",
|
|
183
|
-
detail:
|
|
184
|
-
missing.length === 0
|
|
185
|
-
? "all files present"
|
|
186
|
-
: "missing: " + missing.join(", "),
|
|
174
|
+
detail: missing.length === 0 ? "all files present" : "missing: " + missing.join(", "),
|
|
187
175
|
});
|
|
188
176
|
|
|
189
177
|
// Daemon log
|
package/src/core/runner.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { appendAudit, readState, writeState } from "../utils/logger";
|
|
|
8
8
|
import type { AuditEntry, JobState } from "../types";
|
|
9
9
|
import { getConfig } from "../utils/config";
|
|
10
10
|
import { buildSystemPrompt } from "../chat/identity";
|
|
11
|
+
import { buildEmployeePrompt } from "../chat/employee-prompt";
|
|
12
|
+
import { getEmployee } from "./employees";
|
|
11
13
|
import { scanAgents } from "./agents";
|
|
12
14
|
import { truncate, formatToolUse } from "../utils/format-activity";
|
|
13
15
|
import { getMcpServers } from "../mcp";
|
|
@@ -300,13 +302,23 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
300
302
|
writeState(state);
|
|
301
303
|
|
|
302
304
|
try {
|
|
303
|
-
|
|
305
|
+
let cwd = homedir();
|
|
304
306
|
let output: RunnerOutput;
|
|
305
307
|
|
|
306
|
-
// Resolve system prompt:
|
|
308
|
+
// Resolve system prompt: employee > agent > default
|
|
307
309
|
let systemPrompt: string;
|
|
308
310
|
let agentModel: string | undefined;
|
|
309
|
-
if (job.
|
|
311
|
+
if (job.employee) {
|
|
312
|
+
const empPrompt = buildEmployeePrompt(job.employee);
|
|
313
|
+
if (empPrompt) {
|
|
314
|
+
systemPrompt = empPrompt;
|
|
315
|
+
} else {
|
|
316
|
+
systemPrompt = buildSystemPrompt("job");
|
|
317
|
+
}
|
|
318
|
+
const emp = getEmployee(job.employee);
|
|
319
|
+
if (emp?.model) agentModel = emp.model;
|
|
320
|
+
if (emp?.repo && existsSync(emp.repo)) cwd = emp.repo;
|
|
321
|
+
} else if (job.agent) {
|
|
310
322
|
const agents = scanAgents();
|
|
311
323
|
const agentDef = agents.find((a) => a.name === job.agent);
|
|
312
324
|
if (agentDef) {
|
|
@@ -372,12 +384,6 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
372
384
|
};
|
|
373
385
|
writeState(freshState);
|
|
374
386
|
|
|
375
|
-
// Job-local learnings live in state.md (injected into the next run's
|
|
376
|
-
// prompt), not in global self/memory.md. The global memory consolidator
|
|
377
|
-
// only processes chat sessions — routing job output through it created
|
|
378
|
-
// layer violations (transient incidents promoted to durable memory).
|
|
379
|
-
// See src/core/consolidator.ts and AGENTS.md memory architecture notes.
|
|
380
|
-
|
|
381
387
|
return result;
|
|
382
388
|
} catch (err) {
|
|
383
389
|
const duration_ms = Math.round(performance.now() - startMs);
|
package/src/core/scheduler.ts
CHANGED
|
@@ -1,50 +1,10 @@
|
|
|
1
|
-
import { CronExpressionParser } from "cron-parser";
|
|
2
|
-
import { parseDuration } from "../utils/duration";
|
|
3
|
-
import type { ScheduleType } from "../types";
|
|
4
1
|
import { Job } from "../db/models";
|
|
5
2
|
import { runJob } from "./runner";
|
|
6
3
|
import { getConfig } from "../utils/config";
|
|
7
4
|
import { log } from "../utils/log";
|
|
5
|
+
import { computeInitialNextRun, computeNextRun } from "../utils/schedule";
|
|
8
6
|
|
|
9
|
-
export
|
|
10
|
-
scheduleType: ScheduleType,
|
|
11
|
-
schedule: string,
|
|
12
|
-
timezone: string,
|
|
13
|
-
lastRunAt?: Date,
|
|
14
|
-
): Date | null {
|
|
15
|
-
switch (scheduleType) {
|
|
16
|
-
case "cron": {
|
|
17
|
-
const expr = CronExpressionParser.parse(schedule, { tz: timezone });
|
|
18
|
-
return expr.next().toDate();
|
|
19
|
-
}
|
|
20
|
-
case "interval": {
|
|
21
|
-
const ms = parseDuration(schedule);
|
|
22
|
-
const base = lastRunAt || new Date();
|
|
23
|
-
return new Date(base.getTime() + ms);
|
|
24
|
-
}
|
|
25
|
-
case "once":
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function computeInitialNextRun(
|
|
31
|
-
scheduleType: ScheduleType,
|
|
32
|
-
schedule: string,
|
|
33
|
-
timezone: string,
|
|
34
|
-
): Date {
|
|
35
|
-
switch (scheduleType) {
|
|
36
|
-
case "cron": {
|
|
37
|
-
const expr = CronExpressionParser.parse(schedule, { tz: timezone });
|
|
38
|
-
return expr.next().toDate();
|
|
39
|
-
}
|
|
40
|
-
case "interval": {
|
|
41
|
-
const ms = parseDuration(schedule);
|
|
42
|
-
return new Date(Date.now() + ms);
|
|
43
|
-
}
|
|
44
|
-
case "once":
|
|
45
|
-
return new Date(schedule);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
7
|
+
export { computeInitialNextRun, computeNextRun };
|
|
48
8
|
|
|
49
9
|
function isWithinActiveHours(): boolean {
|
|
50
10
|
const config = getConfig();
|
|
@@ -96,13 +56,16 @@ async function tick(): Promise<void> {
|
|
|
96
56
|
log.info({ job: job.name, type: job.scheduleType }, "scheduler: running job");
|
|
97
57
|
runningJobs.add(job.name);
|
|
98
58
|
|
|
99
|
-
runJob(job)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
59
|
+
runJob(job)
|
|
60
|
+
.then((result) => {
|
|
61
|
+
log.info({ job: job.name, status: result.status, duration: result.duration_ms }, "scheduler: job completed");
|
|
62
|
+
})
|
|
63
|
+
.catch((err) => {
|
|
64
|
+
log.error({ err, job: job.name }, "scheduler: job failed");
|
|
65
|
+
})
|
|
66
|
+
.finally(() => {
|
|
67
|
+
runningJobs.delete(job.name);
|
|
68
|
+
});
|
|
106
69
|
|
|
107
70
|
let nextRun: Date | null = null;
|
|
108
71
|
try {
|
package/src/core/skills.ts
CHANGED
|
@@ -26,7 +26,8 @@ export function scanSkills(): SkillInfo[] {
|
|
|
26
26
|
for (const { dir, source } of SKILL_DIRS) {
|
|
27
27
|
if (!existsSync(dir)) continue;
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
30
|
+
for (const entry of entries) {
|
|
30
31
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
31
32
|
|
|
32
33
|
const skillFile = join(dir, entry.name, "SKILL.md");
|
|
@@ -40,14 +41,10 @@ export function scanSkills(): SkillInfo[] {
|
|
|
40
41
|
try {
|
|
41
42
|
meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
|
|
42
43
|
} catch (err) {
|
|
43
|
-
log.warn(
|
|
44
|
-
{ err, skill: entry.name, path: skillFile },
|
|
45
|
-
"failed to parse skill metadata, skipping",
|
|
46
|
-
);
|
|
44
|
+
log.warn({ err, skill: entry.name, path: skillFile }, "failed to parse skill metadata, skipping");
|
|
47
45
|
continue;
|
|
48
46
|
}
|
|
49
|
-
const name =
|
|
50
|
-
(typeof meta.name === "string" ? meta.name : "") || entry.name;
|
|
47
|
+
const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
|
|
51
48
|
|
|
52
49
|
const key = name.toLowerCase();
|
|
53
50
|
if (seen.has(key)) continue;
|
|
@@ -55,8 +52,7 @@ export function scanSkills(): SkillInfo[] {
|
|
|
55
52
|
|
|
56
53
|
skills.push({
|
|
57
54
|
name,
|
|
58
|
-
description:
|
|
59
|
-
typeof meta.description === "string" ? meta.description : "",
|
|
55
|
+
description: typeof meta.description === "string" ? meta.description : "",
|
|
60
56
|
source,
|
|
61
57
|
});
|
|
62
58
|
}
|
|
@@ -72,8 +68,6 @@ export function getSkillNames(): string[] {
|
|
|
72
68
|
export function getSkillsSummary(): string {
|
|
73
69
|
const skills = scanSkills();
|
|
74
70
|
if (skills.length === 0) return "";
|
|
75
|
-
const lines = skills.map((s) =>
|
|
76
|
-
s.description ? `- /${s.name}: ${s.description}` : `- /${s.name}`,
|
|
77
|
-
);
|
|
71
|
+
const lines = skills.map((s) => (s.description ? `- /${s.name}: ${s.description}` : `- /${s.name}`));
|
|
78
72
|
return `Available skills:\n${lines.join("\n")}`;
|
|
79
73
|
}
|