niahere 0.2.80 → 0.2.82
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/README.md +1 -1
- package/package.json +1 -1
- package/src/chat/engine.ts +27 -2
- package/src/cli/job.ts +17 -2
- package/src/core/job-prompt.ts +78 -0
- package/src/core/runner.ts +5 -43
- package/src/db/models/job.ts +2 -0
- package/src/mcp/server.ts +11 -2
- package/src/mcp/tools.ts +20 -1
- package/src/prompts/environment.md +4 -2
- package/src/types/index.ts +1 -1
- package/src/types/job.ts +8 -0
- package/src/utils/job-workspace.ts +19 -0
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ All config and data lives in `~/.niahere/`:
|
|
|
110
110
|
soul.md — how the agent works
|
|
111
111
|
rules.md — behavioral instructions (loaded every session)
|
|
112
112
|
memory.md — persistent facts and context (loaded every session)
|
|
113
|
-
jobs/ — per-job working memory and state (auto-created)
|
|
113
|
+
jobs/ — per-job prompt.md, working memory, and state (auto-created)
|
|
114
114
|
optimizations/ — optimization loop run workspaces
|
|
115
115
|
images/
|
|
116
116
|
reference.webp — visual identity reference image
|
package/package.json
CHANGED
package/src/chat/engine.ts
CHANGED
|
@@ -24,11 +24,14 @@ import { finalizeSession, cancelPending } from "../core/finalizer";
|
|
|
24
24
|
import { log } from "../utils/log";
|
|
25
25
|
import { isRetryableApiError, sleep } from "../utils/retry";
|
|
26
26
|
import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
|
|
27
|
+
import { resolveJobPrompt } from "../core/job-prompt";
|
|
27
28
|
|
|
28
29
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
29
30
|
const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
|
|
30
31
|
const MAX_SEND_RETRIES = 2;
|
|
31
32
|
const SEND_RETRY_DELAYS = [3_000, 8_000];
|
|
33
|
+
const GENERIC_CHAT_ERROR =
|
|
34
|
+
"Claude/Anthropic returned an error without details. This is usually temporary; please try again shortly.";
|
|
32
35
|
|
|
33
36
|
interface SDKUserMessage {
|
|
34
37
|
type: "user";
|
|
@@ -93,6 +96,15 @@ export function buildContentBlocks(text: string, attachments?: Attachment[]): Me
|
|
|
93
96
|
return blocks as MessageParam["content"];
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
/** Convert SDK error text into a channel-safe chat response. */
|
|
100
|
+
export function formatChatError(rawError: string | null | undefined): string {
|
|
101
|
+
const error = rawError?.trim();
|
|
102
|
+
if (!error || error.toLowerCase() === "unknown error") {
|
|
103
|
+
return GENERIC_CHAT_ERROR;
|
|
104
|
+
}
|
|
105
|
+
return `[error] ${error}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
/**
|
|
97
109
|
* Push-based async iterable for streaming user messages to the SDK.
|
|
98
110
|
* Keeps the query subprocess alive between messages.
|
|
@@ -187,7 +199,9 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
187
199
|
const agentDef = agents.find((a) => a.name === jobData.agent);
|
|
188
200
|
if (agentDef) systemPrompt = agentDef.body + "\n\n" + buildContextSuffix("chat");
|
|
189
201
|
}
|
|
190
|
-
|
|
202
|
+
const resolvedPrompt = resolveJobPrompt(jobData);
|
|
203
|
+
const source = resolvedPrompt.source === "file" ? ` from ${resolvedPrompt.filePath}` : "";
|
|
204
|
+
systemPrompt += `\n\n## Job Context\nYou are chatting in the context of job "${jobData.name}" (schedule: ${jobData.schedule}).\n\nJob prompt (${resolvedPrompt.source}${source}):\n${resolvedPrompt.prompt}`;
|
|
191
205
|
}
|
|
192
206
|
}
|
|
193
207
|
|
|
@@ -505,7 +519,18 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
505
519
|
retryPending.onActivity?.("retrying after API error...");
|
|
506
520
|
stream!.push(retryPending.userMessage);
|
|
507
521
|
} else {
|
|
508
|
-
const errorText =
|
|
522
|
+
const errorText = formatChatError(rawError);
|
|
523
|
+
log.error(
|
|
524
|
+
{
|
|
525
|
+
room,
|
|
526
|
+
error: rawError,
|
|
527
|
+
errors,
|
|
528
|
+
subtype: msg.subtype,
|
|
529
|
+
terminal_reason: msg.terminal_reason,
|
|
530
|
+
session_id: msg.session_id,
|
|
531
|
+
},
|
|
532
|
+
"chat send failed with SDK result error",
|
|
533
|
+
);
|
|
509
534
|
await ActiveEngine.unregister(room);
|
|
510
535
|
clearLongRunningTimer();
|
|
511
536
|
pending.resolve({ result: errorText, costUsd: 0, turns: 0 });
|
package/src/cli/job.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { ScheduleType } from "../types";
|
|
|
10
10
|
import { errMsg } from "../utils/errors";
|
|
11
11
|
import { fail, parseArgs, pickFromList, ICON_PASS, ICON_FAIL } from "../utils/cli";
|
|
12
12
|
import { computeInitialNextRun } from "../core/scheduler";
|
|
13
|
+
import { resolveJobPrompt } from "../core/job-prompt";
|
|
13
14
|
|
|
14
15
|
const HELP = `Usage: nia job <command>
|
|
15
16
|
|
|
@@ -104,7 +105,8 @@ export async function jobCommand(): Promise<void> {
|
|
|
104
105
|
const type = job.scheduleType !== "cron" ? ` (${job.scheduleType})` : "";
|
|
105
106
|
const agentTag = job.agent ? ` [${job.agent}]` : "";
|
|
106
107
|
const empTag = job.employee ? ` [emp:${job.employee}]` : "";
|
|
107
|
-
|
|
108
|
+
const promptTag = resolveJobPrompt(job).source === "file" ? " [prompt.md]" : "";
|
|
109
|
+
console.log(` ${icon} ${job.name} ${job.schedule}${type}${tag}${agentTag}${empTag}${promptTag}`);
|
|
108
110
|
}
|
|
109
111
|
if (archived.length > 0) {
|
|
110
112
|
console.log(
|
|
@@ -283,6 +285,15 @@ export async function jobCommand(): Promise<void> {
|
|
|
283
285
|
const updated = await Job.update(name, fields);
|
|
284
286
|
if (!updated) fail(`Job not found: "${name}". Use \`nia job list\` to see available jobs.`);
|
|
285
287
|
console.log(`Job "${name}" updated.`);
|
|
288
|
+
if (fields.prompt !== undefined) {
|
|
289
|
+
const job = await Job.get(name);
|
|
290
|
+
if (job) {
|
|
291
|
+
const resolvedPrompt = resolveJobPrompt(job);
|
|
292
|
+
if (resolvedPrompt.source === "file") {
|
|
293
|
+
console.log(`Note: runtime prompt is still overridden by ${resolvedPrompt.filePath}.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
286
297
|
});
|
|
287
298
|
} catch (err) {
|
|
288
299
|
fail(`Failed to update job: ${errMsg(err)}`);
|
|
@@ -307,7 +318,11 @@ export async function jobCommand(): Promise<void> {
|
|
|
307
318
|
if (job.employee) console.log(` employee: ${job.employee}`);
|
|
308
319
|
if (job.model) console.log(` model: ${job.model}`);
|
|
309
320
|
if (job.stateless) console.log(` stateless: true`);
|
|
310
|
-
|
|
321
|
+
const resolvedPrompt = resolveJobPrompt(job);
|
|
322
|
+
const promptSource =
|
|
323
|
+
resolvedPrompt.source === "file" ? `file (${resolvedPrompt.filePath})` : resolvedPrompt.source;
|
|
324
|
+
console.log(` prompt source: ${promptSource}`);
|
|
325
|
+
console.log(` prompt: ${resolvedPrompt.prompt}`);
|
|
311
326
|
|
|
312
327
|
const state = readState();
|
|
313
328
|
const info = state[job.name];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import type { JobInput, ResolvedJobPrompt } from "../types";
|
|
4
|
+
import { log } from "../utils/log";
|
|
5
|
+
import { getJobDir } from "../utils/job-workspace";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_JOB_PROMPT = "Execute your scheduled tasks.";
|
|
8
|
+
|
|
9
|
+
export { getJobDir } from "../utils/job-workspace";
|
|
10
|
+
|
|
11
|
+
export function resolveJobPrompt(job: JobInput): ResolvedJobPrompt {
|
|
12
|
+
let promptPath: string | null = null;
|
|
13
|
+
try {
|
|
14
|
+
promptPath = join(getJobDir(job.name), "prompt.md");
|
|
15
|
+
} catch (err) {
|
|
16
|
+
log.warn({ err, job: job.name }, "job has no safe workspace; falling back to database prompt");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (promptPath && existsSync(promptPath)) {
|
|
20
|
+
try {
|
|
21
|
+
const filePrompt = readFileSync(promptPath, "utf8").trim();
|
|
22
|
+
if (filePrompt) {
|
|
23
|
+
return { prompt: filePrompt, source: "file", filePath: promptPath };
|
|
24
|
+
}
|
|
25
|
+
log.warn({ job: job.name, promptPath }, "job prompt.md is empty; falling back to database prompt");
|
|
26
|
+
} catch (err) {
|
|
27
|
+
log.warn({ err, job: job.name, promptPath }, "failed to read job prompt.md; falling back to database prompt");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const dbPrompt = job.prompt.trim();
|
|
32
|
+
if (dbPrompt) {
|
|
33
|
+
return { prompt: dbPrompt, source: "database", filePath: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { prompt: DEFAULT_JOB_PROMPT, source: "default", filePath: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build the working memory block for a stateful job. Returns empty string for stateless jobs. */
|
|
40
|
+
export function buildWorkingMemory(jobName: string, stateless?: boolean): string {
|
|
41
|
+
if (stateless) return "";
|
|
42
|
+
|
|
43
|
+
let jobDir: string;
|
|
44
|
+
try {
|
|
45
|
+
jobDir = getJobDir(jobName);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log.warn({ err, job: jobName }, "job has no safe workspace; working memory disabled");
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
mkdirSync(jobDir, { recursive: true });
|
|
51
|
+
const statePath = join(jobDir, "state.md");
|
|
52
|
+
let stateContent = "";
|
|
53
|
+
if (existsSync(statePath)) {
|
|
54
|
+
try {
|
|
55
|
+
stateContent = readFileSync(statePath, "utf8").trim();
|
|
56
|
+
} catch {
|
|
57
|
+
stateContent = "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stateBlock = stateContent ? `\n${stateContent}\n` : "(first run - no prior state)";
|
|
62
|
+
|
|
63
|
+
return `
|
|
64
|
+
|
|
65
|
+
## Working Memory
|
|
66
|
+
|
|
67
|
+
You have a persistent workspace at \`${jobDir}/\`. This directory is yours - create files, organize data, track history, maintain state however you need.
|
|
68
|
+
|
|
69
|
+
Your \`state.md\` from last run:
|
|
70
|
+
${stateBlock}
|
|
71
|
+
|
|
72
|
+
Before finishing, update \`state.md\` with: what you did this run, what you noticed, and what to do or focus on next time. Keep it concise - a working notebook, not a log.`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildJobPrompt(job: JobInput): string {
|
|
76
|
+
const resolved = resolveJobPrompt(job);
|
|
77
|
+
return `Job: ${job.name} (schedule: ${job.schedule})\n\n${resolved.prompt}${buildWorkingMemory(job.name, job.stateless)}`;
|
|
78
|
+
}
|
package/src/core/runner.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { homedir } from "os";
|
|
2
|
-
import { existsSync
|
|
3
|
-
import { join } from "path";
|
|
2
|
+
import { existsSync } from "fs";
|
|
4
3
|
import { randomUUID } from "crypto";
|
|
5
4
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
6
5
|
import type { JobInput, JobResult } from "../types";
|
|
@@ -11,14 +10,16 @@ import { buildSystemPrompt, buildContextSuffix } from "../chat/identity";
|
|
|
11
10
|
import { buildEmployeePrompt } from "../chat/employee-prompt";
|
|
12
11
|
import { getEmployee } from "./employees";
|
|
13
12
|
import { scanAgents } from "./agents";
|
|
13
|
+
import { buildJobPrompt } from "./job-prompt";
|
|
14
14
|
import { truncate, formatToolUse } from "../utils/format-activity";
|
|
15
15
|
import { getMcpServers, type McpSourceContext } from "../mcp";
|
|
16
16
|
import { ActiveEngine } from "../db/models";
|
|
17
|
-
import { getPaths } from "../utils/paths";
|
|
18
17
|
import { log } from "../utils/log";
|
|
19
18
|
import { isRetryableApiError, sleep } from "../utils/retry";
|
|
20
19
|
import { registerActiveHandle, unregisterActiveHandle } from "./active-handles";
|
|
21
20
|
|
|
21
|
+
export { buildWorkingMemory } from "./job-prompt";
|
|
22
|
+
|
|
22
23
|
export type ActivityCallback = (line: string) => void;
|
|
23
24
|
|
|
24
25
|
interface RunnerOutput {
|
|
@@ -284,40 +285,6 @@ export async function runTask(opts: TaskOptions): Promise<RunnerOutput> {
|
|
|
284
285
|
}
|
|
285
286
|
}
|
|
286
287
|
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
// Working memory
|
|
289
|
-
// ---------------------------------------------------------------------------
|
|
290
|
-
|
|
291
|
-
/** Build the working memory block for a stateful job. Returns empty string for stateless jobs. */
|
|
292
|
-
export function buildWorkingMemory(jobName: string, stateless?: boolean): string {
|
|
293
|
-
if (stateless) return "";
|
|
294
|
-
|
|
295
|
-
const jobDir = join(getPaths().jobsDir, jobName);
|
|
296
|
-
mkdirSync(jobDir, { recursive: true });
|
|
297
|
-
const statePath = join(jobDir, "state.md");
|
|
298
|
-
let stateContent = "";
|
|
299
|
-
if (existsSync(statePath)) {
|
|
300
|
-
try {
|
|
301
|
-
stateContent = readFileSync(statePath, "utf8").trim();
|
|
302
|
-
} catch {
|
|
303
|
-
stateContent = "";
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const stateBlock = stateContent ? `\n${stateContent}\n` : "(first run — no prior state)";
|
|
308
|
-
|
|
309
|
-
return `
|
|
310
|
-
|
|
311
|
-
## Working Memory
|
|
312
|
-
|
|
313
|
-
You have a persistent workspace at \`${jobDir}/\`. This directory is yours — create files, organize data, track history, maintain state however you need.
|
|
314
|
-
|
|
315
|
-
Your \`state.md\` from last run:
|
|
316
|
-
${stateBlock}
|
|
317
|
-
|
|
318
|
-
Before finishing, update \`state.md\` with: what you did this run, what you noticed, and what to do or focus on next time. Keep it concise — a working notebook, not a log.`;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
288
|
// ---------------------------------------------------------------------------
|
|
322
289
|
// Public API
|
|
323
290
|
// ---------------------------------------------------------------------------
|
|
@@ -364,12 +331,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
364
331
|
systemPrompt = buildSystemPrompt("job");
|
|
365
332
|
}
|
|
366
333
|
|
|
367
|
-
|
|
368
|
-
? `Job: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`
|
|
369
|
-
: `Job: ${job.name} (schedule: ${job.schedule})\n\nExecute your scheduled tasks.`;
|
|
370
|
-
|
|
371
|
-
// Working memory: give stateful jobs a persistent workspace
|
|
372
|
-
jobPrompt += buildWorkingMemory(job.name, job.stateless);
|
|
334
|
+
const jobPrompt = buildJobPrompt(job);
|
|
373
335
|
|
|
374
336
|
// Model priority: job.model > agent.model > config.model
|
|
375
337
|
const resolvedModel = job.model || agentModel || config.model;
|
package/src/db/models/job.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { CronExpressionParser } from "cron-parser";
|
|
|
3
3
|
import { parseDuration } from "../../utils/duration";
|
|
4
4
|
import { computeInitialNextRun } from "../../utils/schedule";
|
|
5
5
|
import { getConfig } from "../../utils/config";
|
|
6
|
+
import { validateJobName } from "../../utils/job-workspace";
|
|
6
7
|
import type { ScheduleType, JobLifecycle } from "../../types";
|
|
7
8
|
|
|
8
9
|
/** Validate that a schedule string matches its declared type. Throws on mismatch. */
|
|
@@ -96,6 +97,7 @@ export async function create(
|
|
|
96
97
|
model?: string,
|
|
97
98
|
employee?: string,
|
|
98
99
|
): Promise<void> {
|
|
100
|
+
validateJobName(name);
|
|
99
101
|
validateSchedule(schedule, scheduleType);
|
|
100
102
|
const existing = await get(name);
|
|
101
103
|
if (existing) {
|
package/src/mcp/server.ts
CHANGED
|
@@ -17,7 +17,11 @@ export function createNiaMcpServer(sourceCtx?: McpSourceContext) {
|
|
|
17
17
|
{
|
|
18
18
|
name: z.string().describe("Unique job name"),
|
|
19
19
|
schedule: z.string().describe("Cron expression, duration string, or ISO timestamp"),
|
|
20
|
-
prompt: z
|
|
20
|
+
prompt: z
|
|
21
|
+
.string()
|
|
22
|
+
.describe(
|
|
23
|
+
"What the job should do. A non-empty ~/.niahere/jobs/<job-name>/prompt.md overrides this database prompt at runtime.",
|
|
24
|
+
),
|
|
21
25
|
schedule_type: z.enum(["cron", "interval", "once"]).default("cron").describe("Schedule type"),
|
|
22
26
|
always: z.boolean().default(false).describe("If true, runs 24/7 ignoring active hours"),
|
|
23
27
|
agent: z
|
|
@@ -50,7 +54,12 @@ export function createNiaMcpServer(sourceCtx?: McpSourceContext) {
|
|
|
50
54
|
.string()
|
|
51
55
|
.optional()
|
|
52
56
|
.describe("New schedule (cron expression, interval duration, or ISO timestamp)"),
|
|
53
|
-
prompt: z
|
|
57
|
+
prompt: z
|
|
58
|
+
.string()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe(
|
|
61
|
+
"New database prompt. A non-empty ~/.niahere/jobs/<job-name>/prompt.md overrides this at runtime.",
|
|
62
|
+
),
|
|
54
63
|
always: z.boolean().optional().describe("If true, runs 24/7 ignoring active hours"),
|
|
55
64
|
agent: z.string().nullable().optional().describe("Agent name (set null to remove agent)"),
|
|
56
65
|
employee: z.string().nullable().optional().describe("Employee name (set null to remove employee)"),
|
package/src/mcp/tools.ts
CHANGED
|
@@ -11,12 +11,22 @@ import { log } from "../utils/log";
|
|
|
11
11
|
import { classifyMime } from "../utils/attachment";
|
|
12
12
|
import { scanAgents } from "../core/agents";
|
|
13
13
|
import { listEmployeesForMcp } from "../core/employees";
|
|
14
|
+
import { resolveJobPrompt } from "../core/job-prompt";
|
|
14
15
|
import type { McpSourceContext } from "./index";
|
|
15
16
|
|
|
16
17
|
export async function listJobs(): Promise<string> {
|
|
17
18
|
const jobs = await Job.list();
|
|
18
19
|
if (jobs.length === 0) return "No jobs found.";
|
|
19
|
-
|
|
20
|
+
const withPromptSource = jobs.map((job) => {
|
|
21
|
+
const resolvedPrompt = resolveJobPrompt(job);
|
|
22
|
+
return {
|
|
23
|
+
...job,
|
|
24
|
+
prompt: resolvedPrompt.prompt,
|
|
25
|
+
promptSource: resolvedPrompt.source,
|
|
26
|
+
promptPath: resolvedPrompt.filePath,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
return JSON.stringify(withPromptSource, null, 2);
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
export async function addJob(args: {
|
|
@@ -89,6 +99,15 @@ export async function updateJob(args: {
|
|
|
89
99
|
|
|
90
100
|
const updated = await Job.update(args.name, fields);
|
|
91
101
|
if (!updated) return `Job "${args.name}" not found.`;
|
|
102
|
+
if (fields.prompt !== undefined) {
|
|
103
|
+
const job = await Job.get(args.name);
|
|
104
|
+
if (job) {
|
|
105
|
+
const resolvedPrompt = resolveJobPrompt(job);
|
|
106
|
+
if (resolvedPrompt.source === "file") {
|
|
107
|
+
return `Job "${args.name}" updated. Note: runtime prompt is still overridden by ${resolvedPrompt.filePath}.`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
92
111
|
return `Job "${args.name}" updated.`;
|
|
93
112
|
}
|
|
94
113
|
|
|
@@ -39,7 +39,7 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
|
|
|
39
39
|
- Set `stateless: true` to disable working memory (no state.md or workspace)
|
|
40
40
|
- Set `model` to override the default (e.g., `haiku`, `sonnet`, `opus`) — use cheaper models for high-frequency or simple jobs. Priority: job model > agent model > config model.
|
|
41
41
|
- Set `employee` to assign the job to an employee (employee identity takes precedence over agent)
|
|
42
|
-
- **update_job** — update an existing job's schedule, prompt, always, stateless, agent, model, or employee
|
|
42
|
+
- **update_job** — update an existing job's schedule, prompt, always, stateless, agent, model, or employee. If `~/.niahere/jobs/<job-name>/prompt.md` exists and is non-empty, it overrides the database prompt at runtime.
|
|
43
43
|
- **remove_job** — delete a job by name
|
|
44
44
|
- **enable_job** / **disable_job** — toggle a job on or off
|
|
45
45
|
- **archive_job** — archive a job (hidden from list, won't run)
|
|
@@ -68,7 +68,9 @@ Active hours: {{activeStart}}–{{activeEnd}} ({{timezone}}). Jobs respect this;
|
|
|
68
68
|
|
|
69
69
|
Jobs are **stateful by default**. Each job gets a persistent workspace at `~/.niahere/jobs/<job-name>/`. Before each run, the runner reads `state.md` from that directory and injects it into the prompt. The agent should update `state.md` at the end of each run with what it did, what it noticed, and what to focus on next time.
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
Jobs may also keep their task instructions in `~/.niahere/jobs/<job-name>/prompt.md`. If this file exists and is non-empty, it is the runtime job prompt. If it is missing or empty, the job falls back to the database prompt set through `add_job`, `update_job`, or `nia job update --prompt`.
|
|
72
|
+
|
|
73
|
+
The workspace is freeform — the agent can create any files it needs (data, cache, history, etc.). `prompt.md` is the editable task prompt convention; `state.md` is the working-memory convention the runner injects automatically.
|
|
72
74
|
|
|
73
75
|
To disable working memory for a specific job, set `stateless: true` when creating or updating it.
|
|
74
76
|
|
package/src/types/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { Attachment } from "./attachment";
|
|
2
2
|
export type { JobStatus, JobStateStatus, JobLifecycle, ScheduleType, Mode, AttachmentType, ChannelName } from "./enums";
|
|
3
|
-
export type { JobInput, JobResult } from "./job";
|
|
3
|
+
export type { JobInput, JobPromptSource, JobResult, ResolvedJobPrompt } from "./job";
|
|
4
4
|
export type { SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "./engine";
|
|
5
5
|
export type { AuditEntry, JobState, CronState } from "./audit";
|
|
6
6
|
export type { Channel, ChannelFactory } from "./channel";
|
package/src/types/job.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface JobInput {
|
|
|
10
10
|
stateless?: boolean;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export type JobPromptSource = "file" | "database" | "default";
|
|
14
|
+
|
|
15
|
+
export interface ResolvedJobPrompt {
|
|
16
|
+
prompt: string;
|
|
17
|
+
source: JobPromptSource;
|
|
18
|
+
filePath: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
export interface JobResult {
|
|
14
22
|
job: string;
|
|
15
23
|
timestamp: string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
2
|
+
|
|
3
|
+
import { getPaths } from "./paths";
|
|
4
|
+
|
|
5
|
+
export function getJobDir(jobName: string): string {
|
|
6
|
+
const jobsDir = resolve(getPaths().jobsDir);
|
|
7
|
+
const jobDir = resolve(jobsDir, jobName);
|
|
8
|
+
const rel = relative(jobsDir, jobDir);
|
|
9
|
+
|
|
10
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
11
|
+
throw new Error(`Invalid job name "${jobName}": job workspace must stay inside ${jobsDir}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return jobDir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateJobName(jobName: string): void {
|
|
18
|
+
getJobDir(jobName);
|
|
19
|
+
}
|