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.
@@ -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
 
@@ -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
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
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] = {
@@ -1,27 +1,13 @@
1
1
  /**
2
- * Memory consolidator — stage 1 of a two-stage memory architecture.
2
+ * Memory consolidator — stage 1 of the two-stage memory pipeline.
3
3
  *
4
- * After a chat session goes idle, this module reflects on the transcript
5
- * and writes CANDIDATE memories to ~/.niahere/self/staging.md. It never
6
- * writes directly to memory.md or rules.md — the nightly memory-promoter
7
- * job handles promotion once candidates are reinforced (count >= 2) and
8
- * pass durability review.
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
- * Architecture:
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.
@@ -1,9 +1,10 @@
1
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
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 function writePid(pid: number): void {
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.`;
@@ -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([consolidateSession(sessionId, room), summarizeSession(sessionId, room)]);
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 = 'done', updated_at = NOW()
98
+ SET status = ${finalStatus}, updated_at = NOW()
86
99
  WHERE id = ${requestId}
87
100
  `;
88
101
 
89
- log.info({ sessionId, room, messageCount }, "finalizer: completed");
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();
@@ -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 "./daemon";
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
@@ -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
- const cwd = homedir();
305
+ let cwd = homedir();
304
306
  let output: RunnerOutput;
305
307
 
306
- // Resolve system prompt: use agent body if job references an agent, else default
308
+ // Resolve system prompt: employee > agent > default
307
309
  let systemPrompt: string;
308
310
  let agentModel: string | undefined;
309
- if (job.agent) {
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);
@@ -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 function computeNextRun(
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).then((result) => {
100
- log.info({ job: job.name, status: result.status, duration: result.duration_ms }, "scheduler: job completed");
101
- }).catch((err) => {
102
- log.error({ err, job: job.name }, "scheduler: job failed");
103
- }).finally(() => {
104
- runningJobs.delete(job.name);
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 {
@@ -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
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
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
  }