niahere 0.2.81 → 0.2.83

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.81",
3
+ "version": "0.2.83",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -44,7 +44,7 @@
44
44
  "license": "MIT",
45
45
  "private": false,
46
46
  "dependencies": {
47
- "@anthropic-ai/claude-agent-sdk": "^0.2.119",
47
+ "@anthropic-ai/claude-agent-sdk": "^0.2.126",
48
48
  "@anthropic-ai/sdk": "^0.88.0",
49
49
  "@modelcontextprotocol/sdk": "^1.27.1",
50
50
  "@slack/bolt": "^4.6.0",
@@ -24,6 +24,7 @@ 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
@@ -101,6 +102,9 @@ export function formatChatError(rawError: string | null | undefined): string {
101
102
  if (!error || error.toLowerCase() === "unknown error") {
102
103
  return GENERIC_CHAT_ERROR;
103
104
  }
105
+ if (error === "oauth_org_not_allowed") {
106
+ return "[error] This Claude account is not allowed to access the configured organization. Check your Claude login or organization access.";
107
+ }
104
108
  return `[error] ${error}`;
105
109
  }
106
110
 
@@ -198,7 +202,9 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
198
202
  const agentDef = agents.find((a) => a.name === jobData.agent);
199
203
  if (agentDef) systemPrompt = agentDef.body + "\n\n" + buildContextSuffix("chat");
200
204
  }
201
- systemPrompt += `\n\n## Job Context\nYou are chatting in the context of job "${jobData.name}" (schedule: ${jobData.schedule}).\n\nJob prompt:\n${jobData.prompt}`;
205
+ const resolvedPrompt = resolveJobPrompt(jobData);
206
+ const source = resolvedPrompt.source === "file" ? ` from ${resolvedPrompt.filePath}` : "";
207
+ 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}`;
202
208
  }
203
209
  }
204
210
 
@@ -308,6 +314,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
308
314
  permissionMode: "bypassPermissions",
309
315
  includePartialMessages: true,
310
316
  settingSources: ["project", "user"],
317
+ skills: [],
311
318
  };
312
319
 
313
320
  if (sessionId) {
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
- console.log(` ${icon} ${job.name} ${job.schedule}${type}${tag}${agentTag}${empTag}`);
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
- console.log(` prompt: ${job.prompt}`);
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
+ }
@@ -1,6 +1,5 @@
1
1
  import { homedir } from "os";
2
- import { existsSync, mkdirSync, readFileSync } from "fs";
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 {
@@ -118,6 +119,7 @@ export async function runJobWithClaude(
118
119
  cwd,
119
120
  permissionMode: "bypassPermissions",
120
121
  sessionId,
122
+ skills: [],
121
123
  };
122
124
 
123
125
  if (model && model !== "default") {
@@ -284,40 +286,6 @@ export async function runTask(opts: TaskOptions): Promise<RunnerOutput> {
284
286
  }
285
287
  }
286
288
 
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
289
  // ---------------------------------------------------------------------------
322
290
  // Public API
323
291
  // ---------------------------------------------------------------------------
@@ -364,12 +332,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
364
332
  systemPrompt = buildSystemPrompt("job");
365
333
  }
366
334
 
367
- let jobPrompt = job.prompt
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);
335
+ const jobPrompt = buildJobPrompt(job);
373
336
 
374
337
  // Model priority: job.model > agent.model > config.model
375
338
  const resolvedModel = job.model || agentModel || config.model;
@@ -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.string().describe("What the job should do"),
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.string().optional().describe("New prompt"),
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
- return JSON.stringify(jobs, null, 2);
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
- The workspace is freeform the agent can create any files it needs (data, cache, history, etc.). `state.md` is the convention for the runner to inject automatically; everything else is the agent's to organize.
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
 
@@ -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
+ }