Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 57 additions & 8 deletions packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { readFile, access } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { config as loadEnv } from "dotenv";
import { createLLMClient, StateManager, type ProjectConfig, ProjectConfigSchema, type PipelineConfig } from "@actalk/inkos-core";
import {
createLLMClient,
StateManager,
type ProjectConfig,
ProjectConfigSchema,
type PipelineConfig,
} from "@actalk/inkos-core";

export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
Expand All @@ -12,9 +18,11 @@ export async function resolveContext(opts: {
readonly contextFile?: string;
}): Promise<string | undefined> {
if (opts.context) return opts.context;

if (opts.contextFile) {
return readFile(resolve(opts.contextFile), "utf-8");
}

// Read from stdin if piped (non-TTY)
if (!process.stdin.isTTY) {
const chunks: Buffer[] = [];
Expand All @@ -24,6 +32,7 @@ export async function resolveContext(opts: {
const text = Buffer.concat(chunks).toString("utf-8").trim();
if (text.length > 0) return text;
}

return undefined;
}

Expand Down Expand Up @@ -56,30 +65,68 @@ export async function loadConfig(): Promise<ProjectConfig> {
try {
config = JSON.parse(raw);
} catch {
throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
throw new Error(
`inkos.json in ${root} is not valid JSON.\nCheck the file for syntax errors.`,
);
}

// .env overrides inkos.json for LLM settings
const env = process.env;
const llm = (config.llm ?? {}) as Record<string, unknown>;

if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
if (env.INKOS_LLM_TEMPERATURE) {
llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
}
if (env.INKOS_LLM_MAX_TOKENS) {
llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
}
if (env.INKOS_LLM_THINKING_BUDGET) {
llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
}
if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;

config.llm = llm;

// API key ONLY from env — never stored in inkos.json
const apiKey = env.INKOS_LLM_API_KEY;
if (!apiKey) {
throw new Error(
"INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
"INKOS_LLM_API_KEY not set.\nRun 'inkos config set-global' or add it to project .env file.",
);
}
llm.apiKey = apiKey;

// Build notify channels from env and append them to inkos.json notify[]
const envNotify: unknown[] = [];

if (env.INKOS_SERVERCHAN_SEND_URL) {
envNotify.push({
type: "serverchan",
sendUrl: env.INKOS_SERVERCHAN_SEND_URL,
});
}

if (env.INKOS_BARK_DEVICE_KEY) {
envNotify.push({
type: "bark",
serverUrl: env.INKOS_BARK_SERVER_URL ?? "https://api.day.app",
deviceKey: env.INKOS_BARK_DEVICE_KEY,
group: env.INKOS_BARK_GROUP || "InkOS",
level: env.INKOS_BARK_LEVEL ?? "active",
sound: env.INKOS_BARK_SOUND || undefined,
icon: env.INKOS_BARK_ICON || undefined,
url: env.INKOS_BARK_URL || undefined,
});
}

config.notify = [
...(((config.notify ?? []) as unknown[]) || []),
...envNotify,
];

return ProjectConfigSchema.parse(config);
}

Expand All @@ -90,7 +137,7 @@ export function createClient(config: ProjectConfig) {
export function buildPipelineConfig(
config: ProjectConfig,
root: string,
extra?: Partial<Pick<PipelineConfig, "notifyChannels" | "radarSources" | "externalContext">>,
extra?: Partial<PipelineConfig>,
): PipelineConfig {
return {
client: createLLMClient(config.llm),
Expand Down Expand Up @@ -135,12 +182,14 @@ export async function resolveBookId(

if (books.length === 0) {
throw new Error(
"No books found. Create one first:\n inkos book create --title '...' --genre xuanhuan",
"No books found.\nCreate one first:\n inkos book create --title '...' --genre xuanhuan",
);
}

if (books.length === 1) {
return books[0]!;
}

throw new Error(
`Multiple books found: ${books.join(", ")}\nPlease specify a book-id.`,
);
Expand Down
88 changes: 58 additions & 30 deletions packages/core/src/models/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,41 @@ export const NotifyChannelSchema = z.discriminatedUnion("type", [
botToken: z.string().min(1),
chatId: z.string().min(1),
}),

z.object({
type: z.literal("wechat-work"),
webhookUrl: z.string().url(),
}),

z.object({
type: z.literal("feishu"),
webhookUrl: z.string().url(),
}),

z.object({
type: z.literal("webhook"),
url: z.string().url(),
secret: z.string().optional(),
events: z.array(z.string()).default([]),
}),

z.object({
type: z.literal("serverchan"),
sendUrl: z.string().url(),
}),

z.object({
type: z.literal("bark"),
serverUrl: z.string().url().default("https://api.day.app"),
deviceKey: z.string().min(1),
group: z.string().optional(),
url: z.string().min(1).optional(),
sound: z.string().optional(),
icon: z.string().url().optional(),
level: z
.enum(["active", "timeSensitive", "passive", "critical"])
.optional(),
}),
]);

export type NotifyChannel = z.infer<typeof NotifyChannelSchema>;
Expand Down Expand Up @@ -77,37 +98,44 @@ export const ProjectConfigSchema = z.object({
notify: z.array(NotifyChannelSchema).default([]),
detection: DetectionConfigSchema.optional(),
modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(),
daemon: z.object({
schedule: z.object({
radarCron: z.string().default("0 */6 * * *"),
writeCron: z.string().default("*/15 * * * *"),
daemon: z
.object({
schedule: z
.object({
radarCron: z.string().default("0 */6 * * *"),
writeCron: z.string().default("*/15 * * * *"),
})
.default({
radarCron: "0 */6 * * *",
writeCron: "*/15 * * * *",
}),
maxConcurrentBooks: z.number().int().min(1).default(3),
chaptersPerCycle: z.number().int().min(1).max(20).default(1),
retryDelayMs: z.number().int().min(0).default(30_000),
cooldownAfterChapterMs: z.number().int().min(0).default(10_000),
maxChaptersPerDay: z.number().int().min(1).default(50),
qualityGates: QualityGatesSchema.default({
maxAuditRetries: 2,
pauseAfterConsecutiveFailures: 3,
retryTemperatureStep: 0.1,
}),
})
.default({
schedule: {
radarCron: "0 */6 * * *",
writeCron: "*/15 * * * *",
},
maxConcurrentBooks: 3,
chaptersPerCycle: 1,
retryDelayMs: 30_000,
cooldownAfterChapterMs: 10_000,
maxChaptersPerDay: 50,
qualityGates: {
maxAuditRetries: 2,
pauseAfterConsecutiveFailures: 3,
retryTemperatureStep: 0.1,
},
}),
maxConcurrentBooks: z.number().int().min(1).default(3),
chaptersPerCycle: z.number().int().min(1).max(20).default(1),
retryDelayMs: z.number().int().min(0).default(30_000),
cooldownAfterChapterMs: z.number().int().min(0).default(10_000),
maxChaptersPerDay: z.number().int().min(1).default(50),
qualityGates: QualityGatesSchema.default({
maxAuditRetries: 2,
pauseAfterConsecutiveFailures: 3,
retryTemperatureStep: 0.1,
}),
}).default({
schedule: {
radarCron: "0 */6 * * *",
writeCron: "*/15 * * * *",
},
maxConcurrentBooks: 3,
chaptersPerCycle: 1,
retryDelayMs: 30_000,
cooldownAfterChapterMs: 10_000,
maxChaptersPerDay: 50,
qualityGates: {
maxAuditRetries: 2,
pauseAfterConsecutiveFailures: 3,
retryTemperatureStep: 0.1,
},
}),
});

export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
43 changes: 43 additions & 0 deletions packages/core/src/notify/bark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export interface BarkConfig {
readonly serverUrl: string;
readonly deviceKey: string;
readonly group?: string;
readonly url?: string;
readonly sound?: string;
readonly icon?: string;
readonly level?: "active" | "timeSensitive" | "passive" | "critical";
}

export async function sendBark(
config: BarkConfig,
title: string,
body: string,
): Promise<void> {
const serverUrl = config.serverUrl.replace(/\/+$/, "");

const payload: Record<string, string> = {
device_key: config.deviceKey,
title,
body,
};

if (config.group) payload.group = config.group;
if (config.url) payload.url = config.url;
if (config.sound) payload.sound = config.sound;
if (config.icon) payload.icon = config.icon;
if (config.level) payload.level = config.level;

const response = await fetch(`${serverUrl}/push`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(payload),
});

const text = await response.text();

if (!response.ok) {
throw new Error(`Bark send failed: ${response.status} ${text}`);
}
}
44 changes: 39 additions & 5 deletions packages/core/src/notify/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { sendTelegram } from "./telegram.js";
import { sendFeishu } from "./feishu.js";
import { sendWechatWork } from "./wechat-work.js";
import { sendWebhook, type WebhookPayload } from "./webhook.js";
import { sendServerChan } from "./serverchan.js";
import { sendBark } from "./bark.js";

export interface NotifyMessage {
readonly title: string;
Expand All @@ -24,38 +26,70 @@ export async function dispatchNotification(
fullText,
);
break;

case "feishu":
await sendFeishu(
{ webhookUrl: channel.webhookUrl },
message.title,
message.body,
);
break;

case "wechat-work":
await sendWechatWork(
{ webhookUrl: channel.webhookUrl },
fullText,
);
break;

case "webhook":
// Webhook channels are handled by dispatchWebhookEvent for structured events.
// For generic text notifications, send as a pipeline-complete event.
await sendWebhook(
{ url: channel.url, secret: channel.secret, events: channel.events },
{
url: channel.url,
secret: channel.secret,
events: channel.events,
},
{
event: "pipeline-complete",
bookId: "",
timestamp: new Date().toISOString(),
data: { title: message.title, body: message.body },
data: {
title: message.title,
body: message.body,
},
},
);
break;

case "serverchan":
await sendServerChan(
{ sendUrl: channel.sendUrl },
message.title,
message.body,
);
break;

case "bark":
await sendBark(
{
serverUrl: channel.serverUrl,
deviceKey: channel.deviceKey,
group: channel.group,
url: channel.url,
sound: channel.sound,
icon: channel.icon,
level: channel.level,
},
message.title,
message.body,
);
break;
}
} catch (e) {
// Log but don't throw — notification failure shouldn't block pipeline
process.stderr.write(
`[notify] ${channel.type} failed: ${e}\n`,
);
process.stderr.write(`[notify] ${channel.type} failed: ${e}\n`);
}
});

Expand Down
Loading