niahere 0.2.32 → 0.2.34
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 +1 -1
- package/src/chat/engine.ts +1 -95
- package/src/cli/job.ts +73 -9
- package/src/cli/status.ts +3 -2
- package/src/core/runner.ts +68 -3
- package/src/db/models/job.ts +4 -0
- package/src/mcp/server.ts +22 -1
- package/src/mcp/tools.ts +29 -0
- package/src/prompts/environment.md +13 -2
- package/src/utils/format-activity.ts +99 -0
- package/src/utils/format.ts +12 -0
package/package.json
CHANGED
package/src/chat/engine.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { randomUUID } from "crypto";
|
|
|
8
8
|
import { buildSystemPrompt } from "./identity";
|
|
9
9
|
import { Session, Message, ActiveEngine } from "../db/models";
|
|
10
10
|
import type { Attachment, SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "../types";
|
|
11
|
+
import { truncate, formatToolUse } from "../utils/format-activity";
|
|
11
12
|
|
|
12
13
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
13
14
|
|
|
@@ -97,101 +98,6 @@ interface PendingResult {
|
|
|
97
98
|
reject: (error: Error) => void;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
function truncate(s: string, max: number): string {
|
|
101
|
-
const oneline = s.replace(/\n/g, " ").trim();
|
|
102
|
-
return oneline.length > max ? oneline.slice(0, max) + "…" : oneline;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function formatToolUse(tool: string, input: any): string {
|
|
106
|
-
if (!input || typeof input !== "object") return tool.toLowerCase();
|
|
107
|
-
|
|
108
|
-
switch (tool) {
|
|
109
|
-
// File operations
|
|
110
|
-
case "Bash":
|
|
111
|
-
return input.description
|
|
112
|
-
? truncate(input.description, 60)
|
|
113
|
-
: input.command ? `$ ${truncate(input.command, 55)}` : "running command";
|
|
114
|
-
case "Read":
|
|
115
|
-
return input.file_path ? `reading ${basename(input.file_path)}` : "reading file";
|
|
116
|
-
case "Edit":
|
|
117
|
-
return input.file_path ? `editing ${basename(input.file_path)}` : "editing file";
|
|
118
|
-
case "Write":
|
|
119
|
-
return input.file_path ? `writing ${basename(input.file_path)}` : "writing file";
|
|
120
|
-
case "NotebookEdit":
|
|
121
|
-
return input.file_path ? `editing notebook ${basename(input.file_path)}` : "editing notebook";
|
|
122
|
-
|
|
123
|
-
// Search operations
|
|
124
|
-
case "Grep":
|
|
125
|
-
return input.pattern ? `searching for "${truncate(input.pattern, 35)}"` : "searching code";
|
|
126
|
-
case "Glob":
|
|
127
|
-
return input.pattern ? `finding ${truncate(input.pattern, 40)}` : "finding files";
|
|
128
|
-
case "ToolSearch":
|
|
129
|
-
return input.query ? `looking up tool: ${truncate(input.query, 40)}` : "searching tools";
|
|
130
|
-
|
|
131
|
-
// Agent & task operations
|
|
132
|
-
case "Agent":
|
|
133
|
-
return input.description ? `⟩ ${truncate(input.description, 55)}` : "running agent";
|
|
134
|
-
case "Task":
|
|
135
|
-
return input.description || input.prompt?.slice(0, 50) || "running task";
|
|
136
|
-
case "TaskCreate":
|
|
137
|
-
return input.description ? `starting: ${truncate(input.description, 45)}` : "creating task";
|
|
138
|
-
case "TaskGet":
|
|
139
|
-
case "TaskOutput":
|
|
140
|
-
return "checking task progress";
|
|
141
|
-
case "TaskList":
|
|
142
|
-
return "listing tasks";
|
|
143
|
-
case "TaskStop":
|
|
144
|
-
return "stopping task";
|
|
145
|
-
case "TaskUpdate":
|
|
146
|
-
return "updating task";
|
|
147
|
-
case "SendMessage":
|
|
148
|
-
return input.to ? `messaging ${truncate(String(input.to), 30)}` : "sending message";
|
|
149
|
-
|
|
150
|
-
// Web operations
|
|
151
|
-
case "WebFetch":
|
|
152
|
-
return input.url ? `fetching ${truncate(input.url, 50)}` : "fetching url";
|
|
153
|
-
case "WebSearch":
|
|
154
|
-
return input.query ? `web search: ${truncate(input.query, 40)}` : "searching the web";
|
|
155
|
-
|
|
156
|
-
// Planning & workflow
|
|
157
|
-
case "EnterPlanMode":
|
|
158
|
-
return "entering plan mode";
|
|
159
|
-
case "ExitPlanMode":
|
|
160
|
-
return "exiting plan mode";
|
|
161
|
-
case "EnterWorktree":
|
|
162
|
-
return "creating worktree";
|
|
163
|
-
case "ExitWorktree":
|
|
164
|
-
return "leaving worktree";
|
|
165
|
-
|
|
166
|
-
// Skill & todo
|
|
167
|
-
case "Skill":
|
|
168
|
-
return input.skill ? `using /${truncate(input.skill, 40)}` : "invoking skill";
|
|
169
|
-
case "TodoWrite":
|
|
170
|
-
case "TodoRead":
|
|
171
|
-
return tool === "TodoWrite" ? "updating checklist" : "reading checklist";
|
|
172
|
-
|
|
173
|
-
// LSP
|
|
174
|
-
case "LSP":
|
|
175
|
-
return input.command ? `lsp: ${truncate(input.command, 50)}` : "querying language server";
|
|
176
|
-
|
|
177
|
-
// MCP tools (plugin_name__tool_name pattern)
|
|
178
|
-
default: {
|
|
179
|
-
// Handle MCP tools like mcp__playwright__browser_navigate
|
|
180
|
-
if (tool.startsWith("mcp__")) {
|
|
181
|
-
const parts = tool.split("__");
|
|
182
|
-
const action = parts[parts.length - 1]?.replace(/_/g, " ") || tool;
|
|
183
|
-
const val = input.url || input.selector || input.text || input.value || "";
|
|
184
|
-
return val ? `${action}: ${truncate(String(val), 40)}` : action;
|
|
185
|
-
}
|
|
186
|
-
const val = input.description || input.command || input.pattern || input.query || input.file_path || "";
|
|
187
|
-
return val ? `${tool.toLowerCase()}: ${truncate(String(val), 50)}` : tool.toLowerCase();
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function basename(path: string): string {
|
|
193
|
-
return path.split("/").pop() || path;
|
|
194
|
-
}
|
|
195
101
|
|
|
196
102
|
function sessionFileExists(sessionId: string, cwd: string): boolean {
|
|
197
103
|
// SDK stores sessions at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
|
package/src/cli/job.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { readState, readAudit } from "../utils/logger";
|
|
|
4
4
|
import { getConfig } from "../utils/config";
|
|
5
5
|
import { runJob } from "../core/runner";
|
|
6
6
|
import { localTime } from "../utils/time";
|
|
7
|
+
import { formatDuration } from "../utils/format";
|
|
7
8
|
import { Job } from "../db/models";
|
|
8
9
|
import { withDb } from "../db/connection";
|
|
9
10
|
import type { ScheduleType } from "../types";
|
|
@@ -133,6 +134,38 @@ export async function jobCommand(): Promise<void> {
|
|
|
133
134
|
break;
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
case "update": {
|
|
138
|
+
const always = process.argv.includes("--always");
|
|
139
|
+
let cliArgs = process.argv.slice(4).filter((a) => a !== "--always");
|
|
140
|
+
|
|
141
|
+
const name = cliArgs[0];
|
|
142
|
+
if (!name) {
|
|
143
|
+
console.log('Usage: nia job update <name> [--schedule <schedule>] [--prompt <prompt>] [--always]');
|
|
144
|
+
fail('Example: nia job update curator --schedule "4h" --prompt "New prompt"');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const scheduleIdx = cliArgs.indexOf("--schedule");
|
|
148
|
+
const schedule = scheduleIdx !== -1 ? cliArgs[scheduleIdx + 1] : undefined;
|
|
149
|
+
const promptIdx = cliArgs.indexOf("--prompt");
|
|
150
|
+
const prompt = promptIdx !== -1 ? cliArgs.slice(promptIdx + 1).filter((a) => a !== "--always" && a !== "--schedule" && a !== schedule).join(" ") : undefined;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await withDb(async () => {
|
|
154
|
+
const fields: Partial<{ schedule: string; prompt: string; always: boolean }> = {};
|
|
155
|
+
if (schedule) fields.schedule = schedule;
|
|
156
|
+
if (prompt) fields.prompt = prompt;
|
|
157
|
+
if (always) fields.always = always;
|
|
158
|
+
|
|
159
|
+
const updated = await Job.update(name, fields);
|
|
160
|
+
if (!updated) fail(`Job not found: "${name}". Use \`nia job list\` to see available jobs.`);
|
|
161
|
+
console.log(`Job "${name}" updated.`);
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
fail(`Failed to update job: ${errMsg(err)}`);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
136
169
|
case "show": {
|
|
137
170
|
const name = process.argv[4] || await pickJob("Show job");
|
|
138
171
|
|
|
@@ -152,7 +185,7 @@ export async function jobCommand(): Promise<void> {
|
|
|
152
185
|
if (info) {
|
|
153
186
|
console.log(`\n last run: ${localTime(new Date(info.lastRun))}`);
|
|
154
187
|
console.log(` status: ${info.status}`);
|
|
155
|
-
console.log(` duration: ${info.duration_ms}
|
|
188
|
+
console.log(` duration: ${formatDuration(info.duration_ms)}`);
|
|
156
189
|
if (info.error) console.log(` error: ${info.error}`);
|
|
157
190
|
} else {
|
|
158
191
|
console.log("\n never run");
|
|
@@ -163,7 +196,7 @@ export async function jobCommand(): Promise<void> {
|
|
|
163
196
|
console.log("\n recent runs:");
|
|
164
197
|
for (const e of entries) {
|
|
165
198
|
const time = localTime(new Date(e.timestamp));
|
|
166
|
-
const dur = `${e.duration_ms}
|
|
199
|
+
const dur = `${formatDuration(e.duration_ms)}`;
|
|
167
200
|
const icon = e.status === "ok" ? ICON_PASS : ICON_FAIL;
|
|
168
201
|
const summary = e.error || e.result.slice(0, 60).replace(/\n/g, " ") || "-";
|
|
169
202
|
console.log(` ${icon} ${time} ${dur.padStart(8)} ${summary}`);
|
|
@@ -187,7 +220,7 @@ export async function jobCommand(): Promise<void> {
|
|
|
187
220
|
const state = readState();
|
|
188
221
|
const info = state[job.name];
|
|
189
222
|
const status = info
|
|
190
|
-
? `${info.status} (${localTime(new Date(info.lastRun))}, ${info.duration_ms}
|
|
223
|
+
? `${info.status} (${localTime(new Date(info.lastRun))}, ${formatDuration(info.duration_ms)})`
|
|
191
224
|
: "never run";
|
|
192
225
|
const tag = job.always ? " always" : "";
|
|
193
226
|
console.log(` ${job.enabled ? "●" : "○"} ${job.name} [${job.schedule}]${tag} ${status}`);
|
|
@@ -211,10 +244,40 @@ export async function jobCommand(): Promise<void> {
|
|
|
211
244
|
if (!found) fail(`Job not found: ${name}`);
|
|
212
245
|
const job = found as { name: string; schedule: string; prompt: string };
|
|
213
246
|
|
|
214
|
-
console.log(`Running job: ${job.name} (model: ${getConfig().model})`);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
247
|
+
console.log(`Running job: ${job.name} (model: ${getConfig().model})\n`);
|
|
248
|
+
|
|
249
|
+
const MAX_LOG_LINES = 15;
|
|
250
|
+
const logLines: string[] = [];
|
|
251
|
+
let linesRendered = 0;
|
|
252
|
+
|
|
253
|
+
function renderActivity(line: string) {
|
|
254
|
+
logLines.push(line);
|
|
255
|
+
if (logLines.length > MAX_LOG_LINES) logLines.shift();
|
|
256
|
+
|
|
257
|
+
// Clear previously rendered lines
|
|
258
|
+
if (linesRendered > 0) {
|
|
259
|
+
process.stdout.write(`\x1b[${linesRendered}A\x1b[J`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Render current log lines
|
|
263
|
+
const output = logLines.map((l, i) => {
|
|
264
|
+
const dim = i < logLines.length - 1;
|
|
265
|
+
return dim ? ` \x1b[2m${l}\x1b[0m` : ` \x1b[36m▸\x1b[0m ${l}`;
|
|
266
|
+
}).join("\n");
|
|
267
|
+
|
|
268
|
+
process.stdout.write(output + "\n");
|
|
269
|
+
linesRendered = logLines.length;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const result = await runJob(job, renderActivity);
|
|
273
|
+
|
|
274
|
+
// Clear the activity log and show final result
|
|
275
|
+
if (linesRendered > 0) {
|
|
276
|
+
process.stdout.write(`\x1b[${linesRendered}A\x1b[J`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log(`Status: ${result.status}`);
|
|
280
|
+
console.log(`Duration: ${formatDuration(result.duration_ms)}`);
|
|
218
281
|
if (result.result) console.log(`\nResult:\n${result.result}`);
|
|
219
282
|
if (result.error) console.log(`\nError: ${result.error}`);
|
|
220
283
|
break;
|
|
@@ -229,7 +292,7 @@ export async function jobCommand(): Promise<void> {
|
|
|
229
292
|
}
|
|
230
293
|
for (const e of entries) {
|
|
231
294
|
const time = localTime(new Date(e.timestamp));
|
|
232
|
-
const dur = `${e.duration_ms}
|
|
295
|
+
const dur = `${formatDuration(e.duration_ms)}`;
|
|
233
296
|
const status = e.status === "ok" ? ICON_PASS : ICON_FAIL;
|
|
234
297
|
const summary = e.error || e.result.slice(0, 80).replace(/\n/g, " ") || "-";
|
|
235
298
|
console.log(` ${status} ${time} ${dur.padStart(8)} ${e.job} ${summary}`);
|
|
@@ -238,12 +301,13 @@ export async function jobCommand(): Promise<void> {
|
|
|
238
301
|
}
|
|
239
302
|
|
|
240
303
|
default:
|
|
241
|
-
console.log("Usage: nia job <list|show|status|add|remove|enable|disable|run|log|import>\n");
|
|
304
|
+
console.log("Usage: nia job <list|show|status|add|update|remove|enable|disable|run|log|import>\n");
|
|
242
305
|
console.log(" list — list all jobs");
|
|
243
306
|
console.log(" show [name] — full job details + recent runs");
|
|
244
307
|
console.log(" status [name] — quick status check");
|
|
245
308
|
console.log(" add <name> <schedule> <prompt> — add a job (active hours only)")
|
|
246
309
|
console.log(" --always — run 24/7 regardless of active hours");
|
|
310
|
+
console.log(" update <name> [--schedule s] [--prompt p] [--always] — update a job");
|
|
247
311
|
console.log(" remove <name> — delete a job");
|
|
248
312
|
console.log(" enable <name> — enable a job");
|
|
249
313
|
console.log(" disable <name> — disable a job");
|
package/src/cli/status.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { withDb } from "../db/connection";
|
|
|
9
9
|
import { errMsg } from "../utils/errors";
|
|
10
10
|
import { checkForUpdate } from "../utils/update";
|
|
11
11
|
import { ICON_PASS, ICON_FAIL, ICON_RUNNING } from "../utils/cli";
|
|
12
|
+
import { formatDuration } from "../utils/format";
|
|
12
13
|
|
|
13
14
|
type StatusOptions = {
|
|
14
15
|
json: boolean;
|
|
@@ -247,7 +248,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
|
|
|
247
248
|
!stateInfo;
|
|
248
249
|
|
|
249
250
|
const statusIcon = status === "ok" ? ICON_PASS : status === "error" ? ICON_FAIL : status === "running" ? ICON_RUNNING : "\u2217";
|
|
250
|
-
const durationText = stateInfo?.duration_ms === undefined ? "n/a" :
|
|
251
|
+
const durationText = stateInfo?.duration_ms === undefined ? "n/a" : formatDuration(stateInfo.duration_ms);
|
|
251
252
|
const nextText = nextRun ? formatTimeLine(nextRun, now) : "unknown";
|
|
252
253
|
const lastText = lastRun ? formatTimeLine(lastRun, now) : "never";
|
|
253
254
|
const staleText = stale ? " ⚠ stale" : "";
|
|
@@ -294,7 +295,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
|
|
|
294
295
|
for (const [name, info] of fallbackEntries) {
|
|
295
296
|
const last = formatTimeLine(info.lastRun, now);
|
|
296
297
|
const icon = info.status === "ok" ? ICON_PASS : info.status === "error" ? ICON_FAIL : "\u2217";
|
|
297
|
-
console.log(` ${icon} ${name}: ${info.status} (last: ${last}, ${info.duration_ms}
|
|
298
|
+
console.log(` ${icon} ${name}: ${info.status} (last: ${last}, ${formatDuration(info.duration_ms)})`);
|
|
298
299
|
}
|
|
299
300
|
} else if (dbError) {
|
|
300
301
|
console.log(`\nJobs: database unavailable (${errMsg(dbError)})`);
|
package/src/core/runner.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { appendAudit, readState, writeState } from "../utils/logger";
|
|
|
7
7
|
import type { AuditEntry, JobState } from "../types";
|
|
8
8
|
import { getConfig } from "../utils/config";
|
|
9
9
|
import { buildSystemPrompt } from "../chat/identity";
|
|
10
|
+
import { truncate, formatToolUse } from "../utils/format-activity";
|
|
11
|
+
|
|
12
|
+
export type ActivityCallback = (line: string) => void;
|
|
10
13
|
|
|
11
14
|
interface RunnerOutput {
|
|
12
15
|
agentText: string;
|
|
@@ -65,7 +68,12 @@ async function runJobWithCodex(fullPrompt: string, cwd: string, model: string):
|
|
|
65
68
|
// Claude Agent SDK runner
|
|
66
69
|
// ---------------------------------------------------------------------------
|
|
67
70
|
|
|
68
|
-
export async function runJobWithClaude(
|
|
71
|
+
export async function runJobWithClaude(
|
|
72
|
+
systemPrompt: string,
|
|
73
|
+
jobPrompt: string,
|
|
74
|
+
cwd: string,
|
|
75
|
+
onActivity?: ActivityCallback,
|
|
76
|
+
): Promise<RunnerOutput> {
|
|
69
77
|
const sessionId = randomUUID();
|
|
70
78
|
|
|
71
79
|
// One-shot async iterable: emit a single user message then close
|
|
@@ -90,12 +98,69 @@ export async function runJobWithClaude(systemPrompt: string, jobPrompt: string,
|
|
|
90
98
|
|
|
91
99
|
let agentText = "";
|
|
92
100
|
let actualSessionId = sessionId;
|
|
101
|
+
let accumulatedThinking = "";
|
|
102
|
+
let lastThinkingLine = "";
|
|
93
103
|
|
|
94
104
|
try {
|
|
95
105
|
for await (const message of handle) {
|
|
96
106
|
if (message.type === "system" && (message as any).subtype === "init") {
|
|
97
107
|
actualSessionId = (message as any).session_id || sessionId;
|
|
98
108
|
}
|
|
109
|
+
|
|
110
|
+
// Stream activity events
|
|
111
|
+
if (onActivity) {
|
|
112
|
+
const msg = message as any;
|
|
113
|
+
|
|
114
|
+
if (message.type === "stream_event") {
|
|
115
|
+
const event = msg.event;
|
|
116
|
+
if (event?.type === "content_block_start" && event.content_block?.type === "thinking") {
|
|
117
|
+
accumulatedThinking = "";
|
|
118
|
+
lastThinkingLine = "";
|
|
119
|
+
onActivity("thinking...");
|
|
120
|
+
}
|
|
121
|
+
if (event?.type === "content_block_delta") {
|
|
122
|
+
const delta = event.delta;
|
|
123
|
+
if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
124
|
+
accumulatedThinking += delta.thinking;
|
|
125
|
+
const lines = accumulatedThinking.split("\n");
|
|
126
|
+
if (lines.length > 1) {
|
|
127
|
+
const completeLine = lines[lines.length - 2]?.trim();
|
|
128
|
+
if (completeLine && completeLine !== lastThinkingLine) {
|
|
129
|
+
lastThinkingLine = completeLine;
|
|
130
|
+
onActivity(truncate(completeLine, 70));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (event?.type === "content_block_stop") {
|
|
136
|
+
accumulatedThinking = "";
|
|
137
|
+
lastThinkingLine = "";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (message.type === "tool_use_summary") {
|
|
142
|
+
const name = msg.tool_name || "tool";
|
|
143
|
+
onActivity(formatToolUse(name, msg.tool_input));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (message.type === "tool_progress") {
|
|
147
|
+
if (msg.tool_name === "Bash" && msg.content) {
|
|
148
|
+
onActivity(`$ ${truncate(msg.content, 60)}`);
|
|
149
|
+
} else if (msg.content) {
|
|
150
|
+
onActivity(truncate(msg.content, 70));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (message.type === "system") {
|
|
155
|
+
if (msg.subtype === "task_started" && msg.description) {
|
|
156
|
+
onActivity(truncate(msg.description, 60));
|
|
157
|
+
}
|
|
158
|
+
if (msg.subtype === "task_progress" && msg.last_tool_name) {
|
|
159
|
+
onActivity(msg.summary || msg.last_tool_name);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
99
164
|
if (message.type === "result") {
|
|
100
165
|
if (!(message as any).is_error) {
|
|
101
166
|
agentText = (message as any).result || "";
|
|
@@ -116,7 +181,7 @@ export async function runJobWithClaude(systemPrompt: string, jobPrompt: string,
|
|
|
116
181
|
// Public API
|
|
117
182
|
// ---------------------------------------------------------------------------
|
|
118
183
|
|
|
119
|
-
export async function runJob(job: JobInput): Promise<JobResult> {
|
|
184
|
+
export async function runJob(job: JobInput, onActivity?: ActivityCallback): Promise<JobResult> {
|
|
120
185
|
const config = getConfig();
|
|
121
186
|
const timestamp = new Date().toISOString();
|
|
122
187
|
const startMs = performance.now();
|
|
@@ -136,7 +201,7 @@ export async function runJob(job: JobInput): Promise<JobResult> {
|
|
|
136
201
|
} else {
|
|
137
202
|
const systemPrompt = buildSystemPrompt("job");
|
|
138
203
|
const jobPrompt = `Job: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
|
|
139
|
-
output = await runJobWithClaude(systemPrompt, jobPrompt, cwd);
|
|
204
|
+
output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity);
|
|
140
205
|
}
|
|
141
206
|
|
|
142
207
|
const duration_ms = Math.round(performance.now() - startMs);
|
package/src/db/models/job.ts
CHANGED
|
@@ -42,6 +42,10 @@ export async function create(
|
|
|
42
42
|
scheduleType: ScheduleType = "cron",
|
|
43
43
|
nextRunAt?: Date,
|
|
44
44
|
): Promise<void> {
|
|
45
|
+
const existing = await get(name);
|
|
46
|
+
if (existing) {
|
|
47
|
+
throw new Error(`Job "${name}" already exists. Use \`nia job remove ${name}\` first, or choose a different name.`);
|
|
48
|
+
}
|
|
45
49
|
const sql = getSql();
|
|
46
50
|
await sql`
|
|
47
51
|
INSERT INTO jobs (name, schedule, prompt, always, schedule_type, next_run_at)
|
package/src/mcp/server.ts
CHANGED
|
@@ -29,6 +29,19 @@ export function createNiaMcpServer() {
|
|
|
29
29
|
content: [{ type: "text" as const, text: await handlers.addJob(args) }],
|
|
30
30
|
}),
|
|
31
31
|
),
|
|
32
|
+
tool(
|
|
33
|
+
"update_job",
|
|
34
|
+
"Update an existing job's schedule, prompt, or always flag. Only pass fields you want to change.",
|
|
35
|
+
{
|
|
36
|
+
name: z.string().describe("Job name to update"),
|
|
37
|
+
schedule: z.string().optional().describe("New schedule (cron expression, interval duration, or ISO timestamp)"),
|
|
38
|
+
prompt: z.string().optional().describe("New prompt"),
|
|
39
|
+
always: z.boolean().optional().describe("If true, runs 24/7 ignoring active hours"),
|
|
40
|
+
},
|
|
41
|
+
async (args) => ({
|
|
42
|
+
content: [{ type: "text" as const, text: await handlers.updateJob(args) }],
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
32
45
|
tool(
|
|
33
46
|
"remove_job",
|
|
34
47
|
"Delete a scheduled job",
|
|
@@ -135,9 +148,17 @@ export function createNiaMcpServer() {
|
|
|
135
148
|
content: [{ type: "text" as const, text: handlers.addRule(args.rule) }],
|
|
136
149
|
}),
|
|
137
150
|
),
|
|
151
|
+
tool(
|
|
152
|
+
"read_memory",
|
|
153
|
+
"Read all saved memories. Use this to check what you already know before saving duplicates, or to recall context about the owner, past incidents, preferences, etc.",
|
|
154
|
+
{},
|
|
155
|
+
async () => ({
|
|
156
|
+
content: [{ type: "text" as const, text: handlers.readMemory() }],
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
138
159
|
tool(
|
|
139
160
|
"add_memory",
|
|
140
|
-
"Save a concise factual memory for future reference.
|
|
161
|
+
"Save a concise factual memory for future reference. Proactively save personal facts (travel, schedule), work context (decisions, deadlines), and corrections — don't wait to be asked. RULES: Max 300 chars. One insight per entry. NO raw logs, NO transcripts, NO status dumps.",
|
|
141
162
|
{
|
|
142
163
|
entry: z.string().max(300).describe("A single concise insight (max 300 chars, no raw logs or transcripts)"),
|
|
143
164
|
},
|
package/src/mcp/tools.ts
CHANGED
|
@@ -31,6 +31,24 @@ export async function addJob(args: {
|
|
|
31
31
|
return `Job "${args.name}" created (${scheduleType}: ${args.schedule}). Next run: ${nextRunAt.toISOString()}`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export async function updateJob(args: {
|
|
35
|
+
name: string;
|
|
36
|
+
schedule?: string;
|
|
37
|
+
prompt?: string;
|
|
38
|
+
always?: boolean;
|
|
39
|
+
}): Promise<string> {
|
|
40
|
+
const fields: Partial<{ schedule: string; prompt: string; always: boolean }> = {};
|
|
41
|
+
if (args.schedule) fields.schedule = args.schedule;
|
|
42
|
+
if (args.prompt) fields.prompt = args.prompt;
|
|
43
|
+
if (args.always !== undefined) fields.always = args.always;
|
|
44
|
+
|
|
45
|
+
if (Object.keys(fields).length === 0) return "Nothing to update. Pass at least one field (schedule, prompt, or always).";
|
|
46
|
+
|
|
47
|
+
const updated = await Job.update(args.name, fields);
|
|
48
|
+
if (!updated) return `Job "${args.name}" not found.`;
|
|
49
|
+
return `Job "${args.name}" updated.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
export async function removeJob(name: string): Promise<string> {
|
|
35
53
|
const removed = await Job.remove(name);
|
|
36
54
|
return removed ? `Job "${name}" removed.` : `Job "${name}" not found.`;
|
|
@@ -273,6 +291,17 @@ export function disableWatchChannel(name: string): string {
|
|
|
273
291
|
return `Watch channel "${name}" disabled. Takes effect on next message.`;
|
|
274
292
|
}
|
|
275
293
|
|
|
294
|
+
export function readMemory(): string {
|
|
295
|
+
const { selfDir } = getPaths();
|
|
296
|
+
const memoryPath = join(selfDir, "memory.md");
|
|
297
|
+
if (!existsSync(memoryPath)) return "No memories saved yet.";
|
|
298
|
+
const content = readFileSync(memoryPath, "utf8").trim();
|
|
299
|
+
// Extract just the entries, skip the header/instructions
|
|
300
|
+
const lines = content.split("\n").filter((l) => l.startsWith("- ") || l.startsWith("## "));
|
|
301
|
+
if (lines.length === 0) return "No memories saved yet.";
|
|
302
|
+
return lines.join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
276
305
|
export function addMemory(entry: string): string {
|
|
277
306
|
// Guard: reject raw logs, transcripts, and overly long entries
|
|
278
307
|
const trimmed = entry.trim();
|
|
@@ -25,6 +25,7 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
|
|
|
25
25
|
- `interval`: duration string (e.g., "5m", "2h", "1d" = every 5 min/2 hours/1 day)
|
|
26
26
|
- `once`: ISO timestamp for one-time execution (e.g., "2026-03-14T10:00:00")
|
|
27
27
|
- Set `always: true` to run 24/7 (ignores active hours)
|
|
28
|
+
- **update_job** — update an existing job's schedule, prompt, or always flag
|
|
28
29
|
- **remove_job** — delete a job by name
|
|
29
30
|
- **enable_job** / **disable_job** — toggle a job on or off
|
|
30
31
|
- **run_job** — trigger a job to run immediately
|
|
@@ -34,7 +35,8 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
|
|
|
34
35
|
- **remove_watch_channel** — stop watching a Slack channel. Hot-reloads.
|
|
35
36
|
- **enable_watch_channel** / **disable_watch_channel** — toggle a watch channel on/off without removing it. Hot-reloads.
|
|
36
37
|
- **add_rule** — save a behavioral rule (loaded into every session, no restart needed). Use when told "from now on", "always", "never", or "remember to always..."
|
|
37
|
-
- **
|
|
38
|
+
- **read_memory** — recall all saved memories. Check before saving to avoid duplicates, or when you need context about the owner.
|
|
39
|
+
- **add_memory** — save a factual memory. Proactively save personal facts, work context, corrections — don't wait to be asked.
|
|
38
40
|
|
|
39
41
|
Active hours: {{activeStart}}–{{activeEnd}} ({{timezone}}). Jobs respect this; crons (always=true) don't.
|
|
40
42
|
|
|
@@ -90,9 +92,18 @@ Your persona files live in {{selfDir}}/:
|
|
|
90
92
|
**Memory** (`memory.md`) = facts and context. Read on demand when relevant.
|
|
91
93
|
- "2026-03-13: DB was down, Telegram send failed"
|
|
92
94
|
- "Aman prefers terminal over Slack for debugging"
|
|
93
|
-
- Use `add_memory`
|
|
95
|
+
- Use `read_memory` to recall what you know. Use `add_memory` to save new memories.
|
|
94
96
|
|
|
95
97
|
**Which to use?**
|
|
96
98
|
- "From now on, do X" → rule
|
|
97
99
|
- "Remember that X happened" / "I prefer X" → memory
|
|
100
|
+
|
|
101
|
+
### When to save (proactive)
|
|
102
|
+
Don't wait for the user to say "remember this." Proactively save when you learn:
|
|
103
|
+
- Personal facts: travel plans, location, schedule, preferences
|
|
104
|
+
- Work context: project decisions, team changes, deadlines
|
|
105
|
+
- Corrections: user corrected you on something worth remembering
|
|
106
|
+
- Patterns: recurring requests, preferred communication style
|
|
107
|
+
|
|
108
|
+
Example: if the owner says "I'm going home on the 21st, early morning flight" — save it as a memory without being asked. These are facts future sessions need.
|
|
98
109
|
- If unsure, ask.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting utilities for SDK activity messages.
|
|
3
|
+
* Used by both the chat engine and the job runner for live activity display.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function truncate(s: string, max: number): string {
|
|
7
|
+
const oneline = s.replace(/\n/g, " ").trim();
|
|
8
|
+
return oneline.length > max ? oneline.slice(0, max) + "…" : oneline;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function basename(path: string): string {
|
|
12
|
+
return path.split("/").pop() || path;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatToolUse(tool: string, input: any): string {
|
|
16
|
+
if (!input || typeof input !== "object") return tool.toLowerCase();
|
|
17
|
+
|
|
18
|
+
switch (tool) {
|
|
19
|
+
// File operations
|
|
20
|
+
case "Bash":
|
|
21
|
+
return input.description
|
|
22
|
+
? truncate(input.description, 60)
|
|
23
|
+
: input.command ? `$ ${truncate(input.command, 55)}` : "running command";
|
|
24
|
+
case "Read":
|
|
25
|
+
return input.file_path ? `reading ${basename(input.file_path)}` : "reading file";
|
|
26
|
+
case "Edit":
|
|
27
|
+
return input.file_path ? `editing ${basename(input.file_path)}` : "editing file";
|
|
28
|
+
case "Write":
|
|
29
|
+
return input.file_path ? `writing ${basename(input.file_path)}` : "writing file";
|
|
30
|
+
case "NotebookEdit":
|
|
31
|
+
return input.file_path ? `editing notebook ${basename(input.file_path)}` : "editing notebook";
|
|
32
|
+
|
|
33
|
+
// Search operations
|
|
34
|
+
case "Grep":
|
|
35
|
+
return input.pattern ? `searching for "${truncate(input.pattern, 35)}"` : "searching code";
|
|
36
|
+
case "Glob":
|
|
37
|
+
return input.pattern ? `finding ${truncate(input.pattern, 40)}` : "finding files";
|
|
38
|
+
case "ToolSearch":
|
|
39
|
+
return input.query ? `looking up tool: ${truncate(input.query, 40)}` : "searching tools";
|
|
40
|
+
|
|
41
|
+
// Agent & task operations
|
|
42
|
+
case "Agent":
|
|
43
|
+
return input.description ? `⟩ ${truncate(input.description, 55)}` : "running agent";
|
|
44
|
+
case "Task":
|
|
45
|
+
return input.description || input.prompt?.slice(0, 50) || "running task";
|
|
46
|
+
case "TaskCreate":
|
|
47
|
+
return input.description ? `starting: ${truncate(input.description, 45)}` : "creating task";
|
|
48
|
+
case "TaskGet":
|
|
49
|
+
case "TaskOutput":
|
|
50
|
+
return "checking task progress";
|
|
51
|
+
case "TaskList":
|
|
52
|
+
return "listing tasks";
|
|
53
|
+
case "TaskStop":
|
|
54
|
+
return "stopping task";
|
|
55
|
+
case "TaskUpdate":
|
|
56
|
+
return "updating task";
|
|
57
|
+
case "SendMessage":
|
|
58
|
+
return input.to ? `messaging ${truncate(String(input.to), 30)}` : "sending message";
|
|
59
|
+
|
|
60
|
+
// Web operations
|
|
61
|
+
case "WebFetch":
|
|
62
|
+
return input.url ? `fetching ${truncate(input.url, 50)}` : "fetching url";
|
|
63
|
+
case "WebSearch":
|
|
64
|
+
return input.query ? `web search: ${truncate(input.query, 40)}` : "searching the web";
|
|
65
|
+
|
|
66
|
+
// Planning & workflow
|
|
67
|
+
case "EnterPlanMode":
|
|
68
|
+
return "entering plan mode";
|
|
69
|
+
case "ExitPlanMode":
|
|
70
|
+
return "exiting plan mode";
|
|
71
|
+
case "EnterWorktree":
|
|
72
|
+
return "creating worktree";
|
|
73
|
+
case "ExitWorktree":
|
|
74
|
+
return "leaving worktree";
|
|
75
|
+
|
|
76
|
+
// Skill & todo
|
|
77
|
+
case "Skill":
|
|
78
|
+
return input.skill ? `using /${truncate(input.skill, 40)}` : "invoking skill";
|
|
79
|
+
case "TodoWrite":
|
|
80
|
+
case "TodoRead":
|
|
81
|
+
return tool === "TodoWrite" ? "updating checklist" : "reading checklist";
|
|
82
|
+
|
|
83
|
+
// LSP
|
|
84
|
+
case "LSP":
|
|
85
|
+
return input.command ? `lsp: ${truncate(input.command, 50)}` : "querying language server";
|
|
86
|
+
|
|
87
|
+
// MCP tools (plugin_name__tool_name pattern)
|
|
88
|
+
default: {
|
|
89
|
+
if (tool.startsWith("mcp__")) {
|
|
90
|
+
const parts = tool.split("__");
|
|
91
|
+
const action = parts[parts.length - 1]?.replace(/_/g, " ") || tool;
|
|
92
|
+
const val = input.url || input.selector || input.text || input.value || "";
|
|
93
|
+
return val ? `${action}: ${truncate(String(val), 40)}` : action;
|
|
94
|
+
}
|
|
95
|
+
const val = input.description || input.command || input.pattern || input.query || input.file_path || "";
|
|
96
|
+
return val ? `${tool.toLowerCase()}: ${truncate(String(val), 50)}` : tool.toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -40,6 +40,18 @@ export function relativeTime(date: Date, now = new Date()): string {
|
|
|
40
40
|
return deltaMs > 0 ? `in ${text}` : `${text} ago`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export function formatDuration(ms: number): string {
|
|
44
|
+
if (ms < 1000) return `${ms}ms`;
|
|
45
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
46
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
47
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
48
|
+
const seconds = totalSeconds % 60;
|
|
49
|
+
if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
50
|
+
const hours = Math.floor(minutes / 60);
|
|
51
|
+
const remainingMinutes = minutes % 60;
|
|
52
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
export function formatTimeLine(date: string | null | undefined, now = new Date()): string {
|
|
44
56
|
const parsed = safeDate(date);
|
|
45
57
|
if (!parsed) return "unknown";
|