niahere 0.2.19 → 0.2.21
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/defaults/self/memory.md +8 -7
- package/package.json +1 -1
- package/skills/gh-stamp/SKILL.md +29 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/job.ts +1 -1
- package/src/cli/self.ts +74 -0
- package/src/core/runner.ts +119 -39
- package/src/mcp/server.ts +2 -2
- package/src/mcp/tools.ts +25 -5
- package/src/types/config.ts +1 -0
- package/src/utils/config.ts +5 -0
package/defaults/self/memory.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# Memory
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Concise things I've picked up that I don't want to forget. I maintain this myself.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
5
|
+
Rules:
|
|
6
|
+
- One insight per entry, max 200 chars
|
|
7
|
+
- NO raw logs, transcripts, or status dumps
|
|
8
|
+
- NO duplicates — check before adding
|
|
9
|
+
- Good: "curator job can hang — needs timeout recovery"
|
|
10
|
+
- Bad: pasting nia status output or conversation logs
|
|
10
11
|
|
|
11
|
-
Entries are grouped by date. Use `add_memory` tool to append
|
|
12
|
+
Entries are grouped by date. Use `add_memory` tool to append.
|
|
12
13
|
|
|
13
14
|
---
|
|
14
15
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gh-stamp
|
|
3
|
+
description: "Stamp a GitHub PR with an LGTM approval comment. Use when someone wants to approve, stamp, or give a thumbs-up to a pull request."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# gh-stamp
|
|
7
|
+
|
|
8
|
+
Approve-stamp a GitHub PR by posting a comment.
|
|
9
|
+
|
|
10
|
+
## Trigger
|
|
11
|
+
|
|
12
|
+
User says "stamp", "stamp it", "stmap", or similar.
|
|
13
|
+
|
|
14
|
+
- Stamp only → use this skill alone.
|
|
15
|
+
- Stamp + review → run this skill first, then the pr-reviewer skill.
|
|
16
|
+
- Review only → use the pr-reviewer skill, not this.
|
|
17
|
+
|
|
18
|
+
## Steps
|
|
19
|
+
|
|
20
|
+
1. **Find the PR.** Accept a full URL, `owner/repo#number`, or a bare number if the repo is obvious from context. If unclear, ask and stop.
|
|
21
|
+
2. **Post the comment.**
|
|
22
|
+
```sh
|
|
23
|
+
gh pr comment <pr-url-or-number> --body "LGTM, Stamped ✅"
|
|
24
|
+
# or with repo qualifier:
|
|
25
|
+
gh pr comment --repo <owner/repo> <number> --body "LGTM, Stamped ✅"
|
|
26
|
+
```
|
|
27
|
+
3. **Confirm to user:** `LGTM, Stamped ✅`
|
|
28
|
+
|
|
29
|
+
Do not touch anything else on the PR.
|
package/src/cli/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { fail } from "../utils/cli";
|
|
|
12
12
|
import { jobCommand } from "./job";
|
|
13
13
|
import { statusCommand } from "./status";
|
|
14
14
|
import { sendCommand, telegramCommand, slackCommand } from "./channels";
|
|
15
|
+
import { rulesCommand, memoryCommand } from "./self";
|
|
15
16
|
|
|
16
17
|
// Set LOG_LEVEL from config before anything else logs
|
|
17
18
|
try {
|
|
@@ -207,6 +208,16 @@ switch (command) {
|
|
|
207
208
|
break;
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
case "rules": {
|
|
212
|
+
rulesCommand();
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case "memory": {
|
|
217
|
+
memoryCommand();
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
210
221
|
case "history": {
|
|
211
222
|
const room = process.argv[3];
|
|
212
223
|
try {
|
|
@@ -419,6 +430,8 @@ switch (command) {
|
|
|
419
430
|
console.log(" history [room] — recent messages");
|
|
420
431
|
console.log(" logs [-f] [--channel ch] — daemon logs (filter by channel)");
|
|
421
432
|
console.log(" job <sub> — manage jobs");
|
|
433
|
+
console.log(" rules [show|reset] — view or reset rules.md");
|
|
434
|
+
console.log(" memory [show|reset] — view or reset memory.md");
|
|
422
435
|
console.log(" db <sub> — database setup/status/migrate");
|
|
423
436
|
console.log(" skills — list available skills");
|
|
424
437
|
console.log(" config <sub> — get/set/list config values");
|
package/src/cli/job.ts
CHANGED
|
@@ -49,7 +49,7 @@ export async function jobCommand(): Promise<void> {
|
|
|
49
49
|
for (const job of jobs) {
|
|
50
50
|
const tag = job.always ? " always" : "";
|
|
51
51
|
const type = job.scheduleType !== "cron" ? ` (${job.scheduleType})` : "";
|
|
52
|
-
console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag}
|
|
52
|
+
console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag}`);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
});
|
package/src/cli/self.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync, copyFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { getPaths } from "../utils/paths";
|
|
4
|
+
import { fail } from "../utils/cli";
|
|
5
|
+
|
|
6
|
+
function selfFilePath(name: "rules" | "memory"): string {
|
|
7
|
+
const { selfDir } = getPaths();
|
|
8
|
+
return join(selfDir, `${name}.md`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function defaultFilePath(name: "rules" | "memory"): string {
|
|
12
|
+
const projectRoot = join(import.meta.dir, "../..");
|
|
13
|
+
return join(projectRoot, "defaults", "self", `${name}.md`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function show(name: "rules" | "memory"): void {
|
|
17
|
+
const path = selfFilePath(name);
|
|
18
|
+
if (!existsSync(path)) {
|
|
19
|
+
console.log(`No ${name}.md found.`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
console.log(readFileSync(path, "utf8").trim());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function reset(name: "rules" | "memory"): void {
|
|
26
|
+
const path = selfFilePath(name);
|
|
27
|
+
const defaultPath = defaultFilePath(name);
|
|
28
|
+
|
|
29
|
+
if (!existsSync(defaultPath)) {
|
|
30
|
+
fail(`Default ${name}.md template not found.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (existsSync(path)) {
|
|
34
|
+
copyFileSync(path, `${path}.bak`);
|
|
35
|
+
console.log(` backed up → ${name}.md.bak`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
copyFileSync(defaultPath, path);
|
|
39
|
+
console.log(` ${name}.md reset to default.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function rulesCommand(): void {
|
|
43
|
+
const sub = process.argv[3];
|
|
44
|
+
switch (sub) {
|
|
45
|
+
case "show":
|
|
46
|
+
case undefined:
|
|
47
|
+
show("rules");
|
|
48
|
+
break;
|
|
49
|
+
case "reset":
|
|
50
|
+
reset("rules");
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
console.log("Usage: nia rules <show|reset>");
|
|
54
|
+
console.log(" show — display current rules (default)");
|
|
55
|
+
console.log(" reset — reset to default template (backs up current)");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function memoryCommand(): void {
|
|
60
|
+
const sub = process.argv[3];
|
|
61
|
+
switch (sub) {
|
|
62
|
+
case "show":
|
|
63
|
+
case undefined:
|
|
64
|
+
show("memory");
|
|
65
|
+
break;
|
|
66
|
+
case "reset":
|
|
67
|
+
reset("memory");
|
|
68
|
+
break;
|
|
69
|
+
default:
|
|
70
|
+
console.log("Usage: nia memory <show|reset>");
|
|
71
|
+
console.log(" show — display current memory (default)");
|
|
72
|
+
console.log(" reset — reset to default template (backs up current)");
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/core/runner.ts
CHANGED
|
@@ -1,24 +1,123 @@
|
|
|
1
1
|
import { homedir } from "os";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
3
5
|
import type { JobInput, JobResult } from "../types";
|
|
4
6
|
import { appendAudit, readState, writeState } from "../utils/logger";
|
|
5
7
|
import type { AuditEntry, JobState } from "../types";
|
|
6
8
|
import { getConfig } from "../utils/config";
|
|
7
9
|
import { buildSystemPrompt } from "../chat/identity";
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
interface RunnerOutput {
|
|
12
|
+
agentText: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Codex runner
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function resolveCodexPath(): string {
|
|
22
|
+
const candidates = ["/opt/homebrew/bin/codex", "/usr/local/bin/codex"];
|
|
23
|
+
return candidates.find((p) => existsSync(p)) || "codex";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function runJobWithCodex(fullPrompt: string, cwd: string, model: string): Promise<RunnerOutput> {
|
|
27
|
+
const codexPath = resolveCodexPath();
|
|
28
|
+
const args = [codexPath, "exec", fullPrompt, "-C", cwd, "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"];
|
|
29
|
+
if (model && model !== "default") {
|
|
30
|
+
args.splice(3, 0, "-m", model);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const proc = Bun.spawn(args, {
|
|
34
|
+
stdout: "pipe",
|
|
35
|
+
stderr: "pipe",
|
|
36
|
+
env: { ...process.env },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const stdout = await new Response(proc.stdout).text();
|
|
40
|
+
const stderr = await new Response(proc.stderr).text();
|
|
41
|
+
const exitCode = await proc.exited;
|
|
42
|
+
|
|
43
|
+
let agentText = "";
|
|
44
|
+
let sessionId = "";
|
|
45
|
+
for (const line of stdout.split("\n")) {
|
|
46
|
+
if (!line.trim()) continue;
|
|
47
|
+
try {
|
|
48
|
+
const event = JSON.parse(line);
|
|
49
|
+
if (event.type === "thread.started" && event.thread_id) {
|
|
50
|
+
sessionId = event.thread_id;
|
|
51
|
+
}
|
|
52
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message") {
|
|
53
|
+
agentText = event.item.text || "";
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (exitCode !== 0) {
|
|
59
|
+
return { agentText, sessionId, error: stderr.trim() || `exit code ${exitCode}` };
|
|
60
|
+
}
|
|
61
|
+
return { agentText, sessionId };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Claude Agent SDK runner
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
async function runJobWithClaude(systemPrompt: string, jobPrompt: string, cwd: string): Promise<RunnerOutput> {
|
|
69
|
+
const sessionId = randomUUID();
|
|
70
|
+
|
|
71
|
+
// One-shot async iterable: emit a single user message then close
|
|
72
|
+
async function* singleMessage() {
|
|
73
|
+
yield {
|
|
74
|
+
type: "user" as const,
|
|
75
|
+
message: { role: "user" as const, content: jobPrompt },
|
|
76
|
+
parent_tool_use_id: null,
|
|
77
|
+
session_id: "",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const handle = query({
|
|
82
|
+
prompt: singleMessage() as any,
|
|
83
|
+
options: {
|
|
84
|
+
systemPrompt,
|
|
85
|
+
cwd,
|
|
86
|
+
permissionMode: "bypassPermissions",
|
|
87
|
+
sessionId,
|
|
88
|
+
} as any,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
let agentText = "";
|
|
92
|
+
let actualSessionId = sessionId;
|
|
12
93
|
|
|
94
|
+
try {
|
|
95
|
+
for await (const message of handle) {
|
|
96
|
+
if (message.type === "system" && (message as any).subtype === "init") {
|
|
97
|
+
actualSessionId = (message as any).session_id || sessionId;
|
|
98
|
+
}
|
|
99
|
+
if (message.type === "result") {
|
|
100
|
+
if (!(message as any).is_error) {
|
|
101
|
+
agentText = (message as any).result || "";
|
|
102
|
+
} else {
|
|
103
|
+
const errors = (message as any).errors;
|
|
104
|
+
return { agentText: "", sessionId: actualSessionId, error: errors?.join(", ") || "unknown error" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
handle.close();
|
|
110
|
+
}
|
|
13
111
|
|
|
14
|
-
|
|
15
|
-
const systemPrompt = buildSystemPrompt("job");
|
|
16
|
-
return `${systemPrompt}\n\n---\n\nJob: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
|
|
112
|
+
return { agentText, sessionId: actualSessionId };
|
|
17
113
|
}
|
|
18
114
|
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Public API
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
19
119
|
export async function runJob(job: JobInput): Promise<JobResult> {
|
|
20
120
|
const config = getConfig();
|
|
21
|
-
const model = config.model;
|
|
22
121
|
const timestamp = new Date().toISOString();
|
|
23
122
|
const startMs = performance.now();
|
|
24
123
|
|
|
@@ -28,48 +127,29 @@ export async function runJob(job: JobInput): Promise<JobResult> {
|
|
|
28
127
|
writeState(state);
|
|
29
128
|
|
|
30
129
|
try {
|
|
31
|
-
const fullPrompt = buildPrompt(job);
|
|
32
130
|
const cwd = homedir();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
131
|
+
let output: RunnerOutput;
|
|
132
|
+
|
|
133
|
+
if (config.runner === "codex") {
|
|
134
|
+
const fullPrompt = `${buildSystemPrompt("job")}\n\n---\n\nJob: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
|
|
135
|
+
output = await runJobWithCodex(fullPrompt, cwd, config.model);
|
|
136
|
+
} else {
|
|
137
|
+
const systemPrompt = buildSystemPrompt("job");
|
|
138
|
+
const jobPrompt = `Job: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`;
|
|
139
|
+
output = await runJobWithClaude(systemPrompt, jobPrompt, cwd);
|
|
36
140
|
}
|
|
37
|
-
const proc = Bun.spawn(args, {
|
|
38
|
-
stdout: "pipe",
|
|
39
|
-
stderr: "pipe",
|
|
40
|
-
env: { ...process.env },
|
|
41
|
-
});
|
|
42
141
|
|
|
43
|
-
const stdout = await new Response(proc.stdout).text();
|
|
44
|
-
const stderr = await new Response(proc.stderr).text();
|
|
45
|
-
const exitCode = await proc.exited;
|
|
46
142
|
const duration_ms = Math.round(performance.now() - startMs);
|
|
143
|
+
const ok = !output.error;
|
|
47
144
|
|
|
48
|
-
// Parse JSONL events for session ID and final agent message
|
|
49
|
-
let agentText = "";
|
|
50
|
-
let sessionId = "";
|
|
51
|
-
for (const line of stdout.split("\n")) {
|
|
52
|
-
if (!line.trim()) continue;
|
|
53
|
-
try {
|
|
54
|
-
const event = JSON.parse(line);
|
|
55
|
-
if (event.type === "thread.started" && event.thread_id) {
|
|
56
|
-
sessionId = event.thread_id;
|
|
57
|
-
}
|
|
58
|
-
if (event.type === "item.completed" && event.item?.type === "agent_message") {
|
|
59
|
-
agentText = event.item.text || "";
|
|
60
|
-
}
|
|
61
|
-
} catch {}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const ok = exitCode === 0;
|
|
65
145
|
const result: JobResult = {
|
|
66
146
|
job: job.name,
|
|
67
147
|
timestamp,
|
|
68
148
|
status: ok ? "ok" : "error",
|
|
69
|
-
result: agentText.trim(),
|
|
149
|
+
result: output.agentText.trim(),
|
|
70
150
|
duration_ms,
|
|
71
|
-
session_id: sessionId || undefined,
|
|
72
|
-
error:
|
|
151
|
+
session_id: output.sessionId || undefined,
|
|
152
|
+
error: output.error,
|
|
73
153
|
};
|
|
74
154
|
|
|
75
155
|
const auditEntry: AuditEntry = {
|
package/src/mcp/server.ts
CHANGED
|
@@ -96,9 +96,9 @@ export function createNiaMcpServer() {
|
|
|
96
96
|
),
|
|
97
97
|
tool(
|
|
98
98
|
"add_memory",
|
|
99
|
-
"Save a factual memory for future reference. Memories are read on demand, not loaded automatically. Use for
|
|
99
|
+
"Save a concise factual memory for future reference. Memories are read on demand, not loaded automatically. Use for preferences, corrections, or patterns worth keeping. RULES: Max 200 chars. One insight per entry. NO raw logs, NO conversation transcripts, NO status dumps, NO duplicate observations. Bad: pasting nia status output. Good: 'curator job can get stuck in running state — needs timeout recovery'.",
|
|
100
100
|
{
|
|
101
|
-
entry: z.string().describe("
|
|
101
|
+
entry: z.string().max(300).describe("A single concise insight (max 200 chars, no raw logs or transcripts)"),
|
|
102
102
|
},
|
|
103
103
|
async (args) => ({
|
|
104
104
|
content: [{ type: "text" as const, text: handlers.addMemory(args.entry) }],
|
package/src/mcp/tools.ts
CHANGED
|
@@ -230,19 +230,39 @@ export function addRule(rule: string): string {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
export function addMemory(entry: string): string {
|
|
233
|
+
// Guard: reject raw logs, transcripts, and overly long entries
|
|
234
|
+
const trimmed = entry.trim();
|
|
235
|
+
if (!trimmed) return "Rejected: empty entry.";
|
|
236
|
+
if (trimmed.length > 300) return "Rejected: too long (max 300 chars). Distill to a single concise insight.";
|
|
237
|
+
if (trimmed.includes("[Thread context]") || trimmed.includes("[Current messag")) return "Rejected: no raw conversation transcripts.";
|
|
238
|
+
if (trimmed.split("\n").length > 5) return "Rejected: too many lines. One concise insight per memory.";
|
|
239
|
+
|
|
233
240
|
const { selfDir } = getPaths();
|
|
234
241
|
const memoryPath = join(selfDir, "memory.md");
|
|
242
|
+
const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
|
|
243
|
+
|
|
244
|
+
// Deduplicate: skip if a substantially similar entry already exists
|
|
245
|
+
const normalized = trimmed.toLowerCase().replace(/[^a-z0-9 ]/g, "");
|
|
246
|
+
const lines = existing.split("\n").filter((l) => l.startsWith("- "));
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
const norm = line.slice(2).toLowerCase().replace(/[^a-z0-9 ]/g, "");
|
|
249
|
+
// Check if >60% of words overlap
|
|
250
|
+
const newWords = new Set(normalized.split(/\s+/).filter(Boolean));
|
|
251
|
+
const oldWords = new Set(norm.split(/\s+/).filter(Boolean));
|
|
252
|
+
if (newWords.size === 0) continue;
|
|
253
|
+
let overlap = 0;
|
|
254
|
+
for (const w of newWords) { if (oldWords.has(w)) overlap++; }
|
|
255
|
+
if (overlap / newWords.size > 0.6) return "Rejected: similar memory already exists.";
|
|
256
|
+
}
|
|
257
|
+
|
|
235
258
|
const date = new Date().toISOString().slice(0, 10);
|
|
236
259
|
const header = `\n## ${date}`;
|
|
237
260
|
|
|
238
|
-
const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
|
|
239
261
|
if (existing.includes(header)) {
|
|
240
|
-
|
|
241
|
-
const updated = existing.replace(header, `${header}\n- ${entry}`);
|
|
262
|
+
const updated = existing.replace(header, `${header}\n- ${trimmed}`);
|
|
242
263
|
writeFileSync(memoryPath, updated, "utf8");
|
|
243
264
|
} else {
|
|
244
|
-
|
|
245
|
-
appendFileSync(memoryPath, `${header}\n- ${entry}\n`, "utf8");
|
|
265
|
+
appendFileSync(memoryPath, `${header}\n- ${trimmed}\n`, "utf8");
|
|
246
266
|
}
|
|
247
267
|
return `Memory saved.`;
|
|
248
268
|
}
|
package/src/types/config.ts
CHANGED
package/src/utils/config.ts
CHANGED
|
@@ -10,6 +10,7 @@ const TIME_RE = /^\d{2}:\d{2}$/;
|
|
|
10
10
|
|
|
11
11
|
const DEFAULTS: Config = {
|
|
12
12
|
model: "default",
|
|
13
|
+
runner: "claude",
|
|
13
14
|
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
14
15
|
activeHours: { start: "00:00", end: "23:59" },
|
|
15
16
|
database_url: DEFAULT_DATABASE_URL,
|
|
@@ -54,6 +55,9 @@ export function loadConfig(): Config {
|
|
|
54
55
|
// Model
|
|
55
56
|
const model = typeof raw.model === "string" ? raw.model : DEFAULTS.model;
|
|
56
57
|
|
|
58
|
+
// Runner — "codex" is opt-in, everything else defaults to "claude"
|
|
59
|
+
const runner: Config["runner"] = raw.runner === "codex" ? "codex" : DEFAULTS.runner;
|
|
60
|
+
|
|
57
61
|
// Timezone
|
|
58
62
|
let timezone = DEFAULTS.timezone;
|
|
59
63
|
if (typeof raw.timezone === "string") {
|
|
@@ -140,6 +144,7 @@ export function loadConfig(): Config {
|
|
|
140
144
|
|
|
141
145
|
return {
|
|
142
146
|
model,
|
|
147
|
+
runner,
|
|
143
148
|
timezone,
|
|
144
149
|
activeHours: { start, end },
|
|
145
150
|
database_url,
|